feat: complete phase2 baseline cdr quality rbac

This commit is contained in:
hectorzhao
2026-06-24 18:40:21 +08:00
parent 7057fd3c42
commit a86de6545f
63 changed files with 7853 additions and 3678 deletions
+290 -4
View File
@@ -4,10 +4,11 @@
## 当前状态
- 当前任务:需求修改/二期变更:号码库第 6、7 步本地实现并提交 Git
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`,当前 B release 仍为 `s33-active-calls-20260623094000`,已在该 release 上覆盖发布 s41 用户/角色删除小改;A `/etc/opensips/opensips.cfg` 已将 `fr_inv_timeout` 从 30 临时调为 95 并重启;T 已新增 `/opt/lisglosips-s40/lisglosips-s40-virtual-calls.py` 并启动 10 路虚拟呼叫场景;号码库第 1-7 步已完成本地代码基线,尚未发布到 B/A、尚未执行 MySQL migration、尚未导入真实 80 万号段;阿里云迁移前需按 S30 Runbook 重新演练
- 最后更新:2026-06-24 11:41 +08:00
- 当前阻塞:无。遗留风险为 Redis 恢复后 OpenSIPS 热路径可能需要重启 `opensips` 才恢复,已纳入 S30 Runbook;本次发现并补齐 B `config-publisher.env`,当前 `lisglosips@config-publisher` 已 active。
- 当前任务:需求修改/二期变更:权限驱动导航和按钮
- 本次变更摘要:已完成登录/刷新响应透出 `user.permissions`,前端新增统一 `can(permission)` 权限封装;菜单按页面权限裁剪,核心真实 API 页面操作按钮按 manage/play 权限裁剪,无权限页面提供明确提示,并避免无权限启动时批量请求导致 403。
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`B 已切换并冻结到 `s42-phase2-business-prefix-20260624140000` 作为二期业务前缀小样本联调基线,MySQL 已补齐号码库 migration `20260624095000_number_library` 与业务前缀 migration `20260624121000_business_prefix_gateway_rewrite`Prisma status 为 up to dateconfig-publisher 当前 active 配置版本为 `1782286812696`,上一版本为 `1782286145681`B preflight 直接执行通过;A 已加载业务前缀 Lua SHA `0b7ee595cdcad2f38a670dd8ff3553387820039e`OpenSIPS 配置 SHA-256 为 `ae1544e9a39d2db114e6981babac042c2ab6d399d59ba597a52b85d597aa5aa5`,最终 `dlg_list` 已清空;T 已完成冻结验收实呼 `s28-1782288889943-ckv83lce@lisglosips-t`,B CDR 入库并计费成功;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练
- 最后更新:2026-06-24 17:50 +08:00
- 当前阻塞:无阻塞。遗留问题:真实 80 万手机号段尚未导入;OpenSIPS dialog 在测试呼叫结束后可能残留,冻结前已通过重启清空,后续应单独分析 dialog 生命周期和 `dlg_end_dlg Operation failed` 根因;本次权限裁剪使用本地 mock API 完成只读账号浏览器冒烟,真实 B 服务器浏览器登录态逐页按钮、真实录音文件播放和 Nginx/X-Accel 链路仍需后续人工复测。
- 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云
- 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显
@@ -89,6 +90,291 @@
## 交接记录
### 2026-06-24 17:50 - 需求修改/二期变更:权限驱动导航和按钮
- 状态:已完成
- 操作服务器:本地
- 完成内容:
- 后端登录/刷新响应新增 `user.permissions`,从用户启用角色的 `role_permissions` 汇总去重并排序后返回;保留原 `roles` 字段。
- 前端新增 `apps/web/src/permissions.js`,提供页面权限表、`permissionSet``can``canAll``canAny`
- `App.jsx` 根据权限裁剪菜单;启动和手动刷新时只请求当前用户有权限的全局 API,避免无权限接口产生批量 403;无权限页面显示明确“无权限访问”提示。
- 核心页面按钮按后端真实权限 key 裁剪:当前通话挂断、客户/供应商管理、充值、客户网关、业务前缀、落地网关、落地线路组、号码库导入、话单录音播放、质检录音播放/评分/抽检规则、用户管理、角色管理。
- 修正角色权限页 dev fixture 的权限 key,使其与后端 `RequirePermissions` 使用的真实 key 保持一致。
- 修改文件:`apps/api/src/modules/auth/auth.types.ts``apps/api/src/modules/auth/auth.repository.ts``apps/api/src/modules/auth/auth.service.ts``apps/api/src/modules/auth/auth.service.spec.ts``apps/api/src/modules/auth/auth.e2e.spec.ts``apps/web/src/permissions.js``apps/web/src/App.jsx``apps/web/src/fixtures/devFixtures.js``apps/web/src/pages/*.jsx``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`corepack pnpm@10.33.0 typecheck``corepack pnpm@10.33.0 lint``corepack pnpm@10.33.0 build``corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/auth/auth.service.spec.ts apps/api/src/modules/auth/auth.e2e.spec.ts --hookTimeout=60000 --maxWorkers=1``corepack pnpm@10.33.0 exec vitest run --hookTimeout=60000 --maxWorkers=1``corepack pnpm@10.33.0 --filter @lisglosips/web dev -- --host 127.0.0.1`
- 验证结果:
- typecheck/lint/build 均通过。
- Auth 定向测试 2 files / 6 tests 通过,确认 login/refresh 均返回 permissions。
- 全量 Vitest 25 files / 88 tests 通过。
- 使用本地临时 mock API 与 in-app Browser 验证只读账号仅含 `dashboard.view/customers.view`:侧边栏只显示“概览 Dashboard”“客户管理”;“供应商管理”“质检中心”等菜单不可见;客户页可查看客户列表,但无“新增客户”“编辑”“充值”“删除”按钮;无 Vite overlay,无应用 console error/warn。
- 回滚方式:回滚上述后端 auth 文件、前端权限/页面文件和本状态文件即可;本次未修改 Prisma schema、未执行数据库迁移、未连服务器、未重启服务。
- 未解决问题:当前按钮级裁剪覆盖真实 API 核心页面;费率与计费、SIP 运维、监控告警、系统设置仍属于待设计/演示页面,后续接真实 API 时需继续补权限 key 和按钮裁剪。真实 B 登录态逐页按钮仍需后续人工复测。
- 下一任务:等待用户指定。
### 2026-06-24 17:30 - 需求修改/二期变更:前端拆分与信息架构整理
- 状态:已完成
- 操作服务器:本地
- 完成内容:
-`App.jsx` 中的页面组件拆到 `apps/web/src/pages/``CdrPage.jsx``QualityPage.jsx``ActiveCallsPage.jsx``CustomersPage.jsx``CustomerGatewaysPage.jsx``VendorsPage.jsx``VendorGatewaysPage.jsx``VendorLineGroupsPage.jsx``BusinessPrefixesPage.jsx``NumberLibraryPage.jsx``RechargeRecordsPage.jsx``RoutesPage.jsx``BillingPage.jsx``SipOpsPage.jsx``MonitoringPage.jsx``SettingsPage.jsx``UsersPage.jsx``RolesPage.jsx``OperationLogsPage.jsx`
- 将页面共享布局组件拆到 `apps/web/src/components/layout.jsx`,包含 `Icon``PageTitle``Panel``ApiNotice``Drawer``Modal``SimpleTable``KeyValue` 等。
- 将格式化和 API 归一化函数拆到 `apps/web/src/utils/formatters.js`,供 App 数据刷新和页面组件复用。
- 将残留静态演示/mock 数据集中到 `apps/web/src/fixtures/devFixtures.js`,避免继续散落在 `App.jsx`;未把这些旧 fixture 接入生产 API 流程。
- `App.jsx` 保留应用入口、登录、导航、全局 API 刷新与页面 props 分发,体积从约 237 KB 降到约 20 KB。
- 修改文件:`apps/web/src/App.jsx``apps/web/src/components/layout.jsx``apps/web/src/utils/formatters.js``apps/web/src/fixtures/devFixtures.js``apps/web/src/pages/*.jsx``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`corepack pnpm@10.33.0 lint``corepack pnpm@10.33.0 typecheck``corepack pnpm@10.33.0 build``corepack pnpm@10.33.0 exec vitest run --hookTimeout=60000 --maxWorkers=1``corepack pnpm@10.33.0 --filter @lisglosips/web dev -- --host 127.0.0.1`
- 验证结果:
- lint/typecheck/build 均通过。
- 全量 Vitest 25 files / 88 tests 通过。
- 使用本地临时 mock API 与 in-app Browser 验证 `http://127.0.0.1:5173/`:登录后可切换“当前通话”“话单中心”“质检中心”“客户管理”,对应页面 `h1` 正确、root 正常挂载、无 Vite overlay、无应用 console error/warn。
- 拆分过程中发现并修复两个运行时拆分问题:`App.jsx` 主壳补回 `Badge` 导入;`emptySamplingRuleForm``CdrPage.jsx` 挪回 `QualityPage.jsx`
- 回滚方式:回滚上述前端拆分文件和本状态文件即可;本次未修改 Prisma schema、未执行数据库迁移、未连服务器、未重启服务。
- 未解决问题:`fixtures/devFixtures.js` 仍承载费率、路由、监控、系统设置等未接真实 API 的旧演示数据;后续应按页面逐步接真实 API 或加 dev-only 开关彻底隔离。真实 B 服务器浏览器登录态逐页按钮仍需后续人工复测。
- 下一任务:等待用户指定。
### 2026-06-24 17:10 - 需求修改/二期变更:质检中心接真实 API
- 状态:已完成
- 操作服务器:本地
- 完成内容:
- 录音列表从静态数据切换为 `api.recordings()`,接入 `GET /recordings`,支持待质检/已质检/全部筛选、条数筛选、加载中、错误提示与空状态。
- 录音详情接入 `GET /recordings/:id`,详情抽屉展示客户、被叫、业务、录音时长、抽样状态、命中规则与既有质检结果。
- 录音播放接入 `GET /recordings/:id/play`,以 blob URL 驱动页面内 `<audio controls>`,支持播放/暂停、进度、播放失败提示和自动播放下一条。
- 质检评分保存接入 `PUT /recordings/:id/review`,提交结果、评分、问题标签和备注;保存后刷新录音列表与当前详情,避免“看似保存但未刷新”。
- 抽检规则接入 `/quality/rules`,支持新增、编辑、启用、禁用、删除;规则表单使用真实客户与落地线路组选项,并支持全部客户/全部线路。
- 修改文件:`apps/web/src/App.jsx``apps/web/src/api.js``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`corepack pnpm@10.33.0 lint``corepack pnpm@10.33.0 typecheck``corepack pnpm@10.33.0 build``corepack pnpm@10.33.0 exec vitest run --hookTimeout=60000 --maxWorkers=1``corepack pnpm@10.33.0 --filter @lisglosips/web dev -- --host 127.0.0.1`
- 验证结果:
- lint/typecheck/build 均通过。
- 全量 Vitest 25 files / 88 tests 通过。
- 使用本地临时 mock API 与 in-app Browser 验证质检中心主流程:进入质检中心、打开录音详情、读取 `/recordings/:id/play` blob、填写标签/备注/评分并保存,页面出现保存反馈且刷新详情与列表;无应用 console error/warn。
- 定向验证录音播放:详情抽屉内 `<audio>` 存在,`src` 为 blob URL,抽样状态与质检控件可见。
- 定向验证抽检规则 CRUD:新增 `QA 保留验证规则` 成功,禁用后状态刷新为禁用,删除后列表移除;无应用 console error/warn。
- 回滚方式:回滚上述 2 个前端文件和本状态文件即可;本次未修改 Prisma schema、未执行数据库迁移、未连服务器、未重启服务。
- 未解决问题:真实 B 服务器浏览器登录态逐页按钮仍需后续人工复测;真实录音文件的播放、Range/Nginx X-Accel 链路仍需在 B 上用实际文件复测;话单信令入口仍是 CDR payload/Call-ID 索引展示,未接独立 HEP/SIP trace 检索接口;CSV 导出仍未实现。
- 下一任务:等待用户指定。
### 2026-06-24 16:55 - 需求修改/二期变更:话单中心前端接真实 API
- 状态:已完成
- 操作服务器:本地
- 完成内容:
- 替换话单中心静态 `cdrs/ratedCdrs` 数据,页面改为调用 `api.cdrs()` 查询真实列表,并调用 `api.cdrDetail()` 按需读取详情。
- 新增话单查询条件:主叫、被叫、客户网关、落地网关、地级市代码、运营商、开始时间、结束时间、每页条数;查询时重置到第一页。
- 新增分页状态:展示当前范围、总数、上一页、下一页,并使用后端 `meta.total/take/skip/hasMore`
- 详情抽屉改为真实契约展示,包含链路信息、关联对象、计费结果、时间轴、录音与信令;信令入口展示 Call-ID、eventId、网关、配置版本和 payload 索引。
- 新增录音播放接线:`api.recordingPlayback()` 拉取 `/recordings/:id/play` blob,详情抽屉内使用 `<audio controls>` 播放;无录音或读取失败时给出禁用/错误状态。
- 保留导出 CSV 入口但禁用,避免误导为已实现导出。
- 修改文件:`apps/web/src/App.jsx``apps/web/src/api.js``apps/web/src/styles.css``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`corepack pnpm@10.33.0 lint``corepack pnpm@10.33.0 build``corepack pnpm@10.33.0 typecheck``corepack pnpm@10.33.0 exec vitest run --hookTimeout=60000 --maxWorkers=1``corepack pnpm@10.33.0 --filter @lisglosips/web dev -- --host 127.0.0.1`
- 验证结果:
- lint/typecheck/build 均通过。
- 全量 Vitest 25 files / 88 tests 通过。
- in-app Browser 验证 `http://127.0.0.1:5173/` 应用壳可加载,标题为 `LisgloSIPS - 聆界SIP管理平台`,无 Vite overlay,无 console error/warn。
- 使用本地临时 mock API 验证登录后话单中心:查询主叫 `1001` 后列表过滤为 1 条;详情抽屉打开成功;计费结果、信令索引、录音 `<audio>` 控件可见;查询不存在主叫 `999999` 后出现空状态且总数为 0console error/warn 为 0。
- 回滚方式:回滚上述 3 个前端文件和本状态文件即可;本次未修改 Prisma schema、未执行数据库迁移、未连服务器、未重启服务。
- 未解决问题:真实 B 服务器浏览器登录态逐页按钮仍需后续人工复测;信令入口当前展示 CDR payload/Call-ID 索引,未接独立 HEP/SIP trace 检索接口;CSV 导出仍未实现。
- 下一任务:等待用户指定。
### 2026-06-24 16:28 - 需求修改/二期变更:CDR/话单后端 API 补强
- 状态:已完成
- 操作服务器:本地
- 完成内容:
- 在现有 `CdrsModule` 上补强话单后端能力,保持路由 `GET /cdrs``GET /cdrs/:id` 不变。
- 列表筛选新增 `startedFrom/startedTo`,并兼容 `from/to``startTime/endTime`;保留主叫、被叫、客户网关、落地网关、归属地城市、运营商、`take/skip` 分页。
- 列表返回新增 `meta: { total, take, skip, hasMore }`,并补充业务前缀、原始被叫、落地主被叫、录音摘要字段。
- 详情返回整理为前端可直接展示的稳定契约,关联 raw CDR、rated CDR、recording、customer、vendor、customer gateway、vendor gateway、line group、business prefix、customer gateway policy,并保留 payload、receivedAt、createdAt。
- 修改文件:`apps/api/src/modules/cdrs/cdrs.repository.ts``apps/api/src/modules/cdrs/cdrs.service.ts``apps/api/src/modules/cdrs/cdrs.service.spec.ts``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/cdrs/cdrs.service.spec.ts --hookTimeout=60000``corepack pnpm@10.33.0 typecheck``corepack pnpm@10.33.0 lint``corepack pnpm@10.33.0 build``corepack pnpm@10.33.0 exec vitest run --hookTimeout=60000 --maxWorkers=1`
- 验证结果:CDR service spec 3 tests 通过;typecheck/lint/build 均通过;全量 Vitest 25 files / 88 tests 通过。
- 回滚方式:回滚上述 3 个 CDR 模块文件和本状态文件即可;本次未修改 Prisma schema、未执行数据库迁移、未连服务器、未重启服务。
- 未解决问题:前端话单列表/详情页尚未接入新契约;如未来要按客户、供应商、落地线路组、SIP 状态码等更多条件筛选,可在当前 `CdrQuery` 上继续扩展。
- 下一任务:等待用户指定。
### 2026-06-24 16:16 - 需求修改/二期变更:S42 二期业务前缀基线冻结
- 状态:已完成
- 操作服务器:A、B、T、本地
- 完成内容:基于当前文档、代码和服务器状态对 `s42-phase2-business-prefix-20260624140000` 执行冻结前收口。补齐 B 当前 release 中 `infra/server-b/s30/lisglosips-release-preflight.sh` 执行权限为 `0755`,直接执行 preflight 通过;确认 B current、API/CDR/Recording/Config Publisher/Nginx/MySQL/Redis/HEP/Prometheus/Grafana activeAPI ready 正常,Prisma 4 条 migration up to dateRedis active/previous 配置版本为 `1782286812696`/`1782286145681`,最新 MySQL/Redis 备份 `/data/backups/*/20260624T055810Z` SHA256 校验通过。抽样确认 Redis `stream:cdr_deadletter` 26 条为早期修复前历史死信,冻结验收呼叫未新增 deadletter。A 侧历史 4 条 dialog 使用 `dlg_end_dlg` 仍返回 `Operation failed` 且未消失,已先执行 `opensips -C` 再重启 OpenSIPS 清空;冻结验收实呼后新增 1 条测试 dialog 残留,再次语法检查并重启 OpenSIPS,最终 `dlg_list` 为空。新增正式冻结报告 `docs/S42_PHASE2_FREEZE_REPORT.md`
- 修改文件:`docs/S42_PHASE2_FREEZE_REPORT.md``IMPLEMENTATION_STATUS.md`;B 当前 release 脚本权限已修正为 `0755`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:本地 `corepack pnpm@10.33.0 typecheck``corepack pnpm@10.33.0 lint``corepack pnpm@10.33.0 build``corepack pnpm@10.33.0 exec vitest run --hookTimeout=60000 --maxWorkers=1`B `lisglosips-release-preflight.sh`、Prisma migrate status、Redis `MGET cfg:active_version cfg:previous_version`、备份 `sha256sum -c SHA256SUMS`、deadletter 抽样;A `opensips -C -f /etc/opensips/opensips.cfg``systemctl restart opensips``lisglosips-call-control dlg_list`T `python3 /opt/lisglosips-s28/lisglosips-s28-sip.py invite --host 100.90.90.90 --port 15060 --bind-host 100.93.185.30 --contact-host 100.93.185.30 --caller s28-ip-1001 --callee 13800138000 --hold 3 --timeout 20`
- 验证结果:本地 typecheck/lint/build 均通过;全量 Vitest 在 `--hookTimeout=60000` 下 25 个测试文件、87 条测试通过;B preflight 直接执行通过,API ready 正常,Prisma up to date,最新 MySQL/Redis 备份哈希通过;T 冻结验收实呼 Call-ID `s28-1782288889943-ckv83lce@lisglosips-t` 收到 `100 Giving it a try``200 OK`、BYE `200 OK`B raw CDR `raw_d5732df32b8d496686512870fb087232` 入库,`caller=s28-ip-1001``callee=13800138000``raw_callee=13800138000``business_prefix=NULL``landing_caller=s28-ip-1001``landing_callee=13800138000``sip_code=200``rating_status=RATED`,客户费用和供应商成本均为 `0.012000`CDR Worker 日志 `processed=1 deadlettered=0`A 最终 `dlg_list` 为空。
- 回滚方式:B 应用可执行 `/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-rollback.sh /opt/lisglosips/releases/s33-active-calls-20260623094000`Redis 可从当前 `1782286812696` 切回上一版本 `1782286145681`,不删除 Stream/deadletterA 可从最近 `/var/backups/lisglosips-phase2/` 备份恢复 `opensips.cfg``lisglosips_hotpath.lua`,先 `opensips -C` 再重启;数据库如必须恢复,使用 `/data/backups/mysql/20260624T055810Z` 先在隔离库验证后进入维护窗口处理。
- 未解决问题:真实 80 万手机号段尚未导入;浏览器登录态逐页按钮未做完整人工复测;OpenSIPS dialog 在测试呼叫结束后可能残留,冻结前已通过重启清空,后续应单独分析 dialog 生命周期和 `dlg_end_dlg Operation failed` 根因;当前仍为本地 KVM/Tailscale 冻结,不进入阿里云生产迁移。
### 2026-06-24 15:42 - 需求修改/二期变更:业务前缀重构第 10 步 A/B/T 端到端验收
- 状态:已完成
- 操作服务器:A、B、T、本地
- 完成内容:复核 B 当前 release `/opt/lisglosips/releases/s42-phase2-business-prefix-20260624140000`A OpenSIPS 与 Redis AUTH 代理 active,热路径 Lua SHA `0b7ee595cdcad2f38a670dd8ff3553387820039e`T UAS `100.93.185.30:50620` 监听正常。临时写入 Step 10 验收数据:业务前缀 `671`、S28 客户网关业务前缀匹配、北京/移动号码段、S28 第一落地网关屏蔽北京、同线路组第二落地网关、落地被叫前缀 `88`、指定主叫 `02160010001`。发布临时配置版本 `1782286145681` 后从 T 发起组合实呼 `s28-1782286191063-ig215mpx@lisglosips-t`A 热路径选择第二落地网关 `vgw_91379ef9dc254ab29039d4b77197`B CDR 入库 `raw_callee=67113800138000``callee=13800138000``business_prefix=671``callee_city_code=110100``callee_operator=MOBILE``landing_caller=02160010001``landing_callee=8813800138000``rating_status=RATED`。随后通过 `/api/v2/active-calls` 验证当前通话列表包含 `callerIp``landingIp`,并通过 `/api/v2/active-calls/:id/hangup``bae.39c14471` 发起强制挂断,返回 `HANGUP_IN_PROGRESS` 且目标进入 state `5`
- 修改文件:`IMPLEMENTATION_STATUS.md`
- 验证结果:B `lisglosips@api``lisglosips@cdr-worker``lisglosips@config-publisher` activeA `opensips``lisglosips-redis-auth-proxy` activeT UAS 监听正常;组合实呼 SIP `100/200/BYE 200`A OpenSIPS 日志记录热路径 allow 与 CDR XADDB CDR Worker `processed=1 deadlettered=0`;当前通话 API 返回 6 条 dialog 并包含呼叫方 IP/落地 IP;强制挂断 API 返回 `HANGUP_IN_PROGRESS`
- 回滚方式:已执行回滚 SQL:S28 客户网关 `callee_match_mode` 恢复 `ANY`,删除临时客户网关业务前缀绑定、临时线路组成员、屏蔽地区、指定主叫池、号码段和运营商规则;第二落地网关 `landing_callee_prefix` 恢复 `NULL`;临时业务前缀 `bp_p2e2e_671` 软删除以保留 CDR 引用;回滚配置已发布为 `1782286812696`manifest 回到 `customerGatewayBusinessPrefixCount=0``callerRewriteCount=0``phoneSegmentCount=0``carrierPrefixRuleCount=0``blockedRegionCount=0`
- 未解决问题:A `dlg_list` 仍有历史测试 dialog 残留,包括旧 S28 呼叫;强制挂断 OpenSIPS MI 对部分 dialog 仍可能返回 `Operation failed` 但会推进终止态;本次验证使用 API 级当前通话/挂断,未在浏览器登录态逐页点击 Web 按钮;真实 80 万手机号段尚未导入。
### 2026-06-24 15:00 - 需求修改/二期变更:业务前缀重构第 9 步 A/B/T 验收续跑与 CDR 修复
- 状态:已完成第 9 步主链路验收;仍有历史 dialog 清理和 Web 登录态页面按钮复测遗留
- 操作服务器:A、B、T、本地
- 完成内容:按用户要求先 ping B,`100.90.90.91` 4 次 ping 0% 丢包,SSH 可达,B current 为 `s42-phase2-business-prefix-20260624140000`API/CDR/Recording/Config Publisher 均 active。重新上传 A/B 候选 Lua/配置,发现直接 `redis-cli SCRIPT LOAD "$(cat file)"` 会丢失末尾换行导致 SHA 为 `e1a853...`,已改用 `redis-cli -x SCRIPT LOAD < file`Redis 中确认 `SCRIPT EXISTS 0b7ee595cdcad2f38a670dd8ff3553387820039e` 返回 1。A 侧首次重启时误将 B 本机 `REDIS_URL` 写入 OpenSIPS,导致 cachedb 连接 `127.0.0.1:6379` 失败;根据 S20 Runbook 修正为 OpenSIPS cachedb URL `redis:s20://127.0.0.1:6380/`,通过 A 本地 `lisglosips-redis-auth-proxy` 连接 B Redis,重新 `opensips -C` 并重启 OpenSIPS,日志显示连接 `127.0.0.1:6380`,无新增 Redis connection refused。T 发起 S28 实呼 `s28-1782283559224-yo6iqlqg@lisglosips-t` 成功,但 B CDR Worker 将事件投 deadletter;定位为二期热路径写 `customer_gateway_policy_id=single_gateway` 占位值,而 `raw_cdrs.customer_gateway_policy_id` 仍有外键。修复 `apps/worker-cdr/src/rating.ts` 的 nullable ID 归一化,将 `single_gateway``no_policy_match` 一并转 `null`,补充单元测试,构建并仅重启 B `lisglosips@cdr-worker`。再次发起实呼 `s28-1782283796126-nx2488rc@lisglosips-t`SIP 100/200/BYE 200 成功,A 日志显示 hotpath allow 和 CDR XADDB CDR Worker `processed=1 deadlettered=0``raw_cdrs` 入库 `customer_gateway_policy_id=NULL``business_prefix_id=NULL``landing_caller=s28-ip-1001``landing_callee=13800138000``rating_status=RATED`。随后发起长呼 `s28-1782283960806-f6ct79b4@lisglosips-t` 验证当前通话/强制挂断控制面,A `dlg_list` 可见目标 dialog `03a.af0b33d6`,包含呼叫方 SDP/IP 与落地侧 SDP/IP;执行 `dlg_end_dlg 03a.af0b33d6` 返回 MI `Operation failed`,但目标 dialog 状态进入 `5``timeout=0`,二次检查目标 Call-ID/ID 已不可见,T 测试脚本收到平台侧 BYE 形式结束。该行为与 API 现有兼容逻辑一致:Operation failed 后若 dialog 消失则视为 `HANGUP_CONFIRMED`,若 state=5 则视为 `HANGUP_IN_PROGRESS`
- 修改文件:`apps/worker-cdr/src/rating.ts``apps/worker-cdr/src/rating.spec.ts``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`ping 100.90.90.91 -n 4`、B SSH 服务状态检查、B Redis `SCRIPT LOAD`/`SCRIPT EXISTS`、A `lisglosips-redis-load-hotpath`、A `opensips -C -f /etc/opensips/opensips.cfg`、A `systemctl restart opensips`、T `python3 /opt/lisglosips-s28/lisglosips-s28-sip.py invite`、A `/usr/local/sbin/lisglosips-call-control dlg_list`、A `/usr/local/sbin/lisglosips-call-control dlg_end_dlg 03a.af0b33d6`、B `raw_cdrs` 查询、`node_modules\.bin\vitest.cmd run apps/worker-cdr/src/rating.spec.ts``node_modules\.bin\tsc.cmd -p apps/worker-cdr/tsconfig.json`
- 验证结果:B ping 0% 丢包,SSH 可达;A OpenSIPS 与 Redis proxy activeA OpenSIPS 新配置语法检查通过并重启成功,日志显示 cachedb 连接 `127.0.0.1:6380`B API ready 正常,config-publisher 仍为 schemaVersion 2 配置版本 `1782282403866`;Worker 定向测试 4 条通过;CDR Worker 修复后实呼入库并计费,`deadlettered=0`;强制挂断控制面对目标 dialog 生效,目标二次检查不可见。
- 回滚方式:A 可恢复 `/var/backups/lisglosips-phase2/20260624144208` 中的 `opensips.cfg``lisglosips_hotpath.lua``opensips -C` 并重启。B 可切回 `/opt/lisglosips/releases/s33-active-calls-20260623094000` 并重启 API/CDR/Recording/Config Publisher;数据库如需回滚,使用 `/data/backups/mysql/20260624T055810Z` 先在隔离环境验证。CDR Worker 单点修复可恢复上一 release 的 `apps/worker-cdr/dist` 并重启 `lisglosips@cdr-worker`
- 未解决问题:A `dlg_list` 仍显示几条历史 S28 测试 dialog 残留,需要单独清理或确认 dialog timeout;Web 页面级“当前通话”列表和按钮因缺少登录态未通过浏览器/API 直接复测,本次以底层 MI 和实呼验证为准;真实 80 万手机号段仍未导入,号码归属地为 UNKNOWN。
- 下一任务:清理历史 dialog 后,在网页登录态下复测“当前通话”页面展示呼叫方 IP/落地 IP 与强制挂断按钮;随后按需导入真实号码库数据并做业务前缀命中场景测试。
### 2026-06-24 14:40 - 需求修改/二期变更:业务前缀重构第 9 步 B 侧发布与 A 侧发布阻塞
- 状态:部分完成;B 侧数据库 migration、release 切换、API/Worker 重启、Redis 配置发布和 preflight 已完成;A 侧 OpenSIPS 未重启到新热路径,A/B/T 实呼验收未完成;当前因 B 再次不可达阻塞
- 操作服务器:B、A、本地;T 未执行实呼
- 完成内容:B 开机恢复后完成 S30 备份,MySQL 备份 `/data/backups/mysql/20260624T055810Z` 与 Redis 备份 `/data/backups/redis/20260624T055810Z` 哈希校验通过。创建 B 新 release `/opt/lisglosips/releases/s42-phase2-business-prefix-20260624140000`,从旧 current 复制基底并覆盖本地构建产物、Prisma schema/migration、业务前缀模块、迁移脚本与 Web dist。执行 Prisma generate 后,发现不能用应用账号跑 DDL,改用 `/etc/lisglosips/secrets/mysql-migrate.env` 迁移账号。号码库 migration `20260624095000_number_library` 先因应用账号无 CREATE 权限失败,随后又暴露 `vendor_gateway_blocked_regions_vendor_gateway_id_region_scope_province_code_city_code_key` 索引名过长;已修正本地 migration 为短名 `vgb_regions_gateway_scope_region_key`,并在 B 上补齐部分落地后的剩余 DDL,再 `prisma migrate resolve --applied`。业务前缀 migration `20260624121000_business_prefix_gateway_rewrite` 暴露 `customer_gateway_business_prefixes_gateway_id_business_prefix_id_key` 索引名过长;已修正为 `cgbp_gateway_business_prefix_key`,在 B 上幂等补齐客户网关新字段、多 IP 表、业务前缀绑定表、主叫前缀表、落地主叫池、CDR 新字段及索引/外键,再标记 applied。Prisma status 显示 4 条 migration 全部 up to date。旧客户网关迁移脚本 dry-run 显示 2 个 safe、0 blockersapply 成功,更新 2 个客户网关,新增 1 条主叫前缀,写入 1 条 outbox。B current 已切换到 `s42-phase2-business-prefix-20260624140000``lisglosips@api``lisglosips@cdr-worker``lisglosips@recording-worker``lisglosips@config-publisher` 重启后均 active。API live/ready 和 HTTPS ready 通过,config-publisher 发布 schemaVersion 2 配置版本 `1782282403866`B preflight 通过。A 侧候选 `opensips.cfg``lisglosips_hotpath.lua` 已上传到 `/tmp` 并安装到 `/etc/opensips``opensips -C` 通过,备份目录为 `/var/backups/lisglosips-phase2/20260624142935`;但发现本地模板 `cachedb_url` 占位符仍未替换,且 B 随后再次不可达,无法安全读取 Redis URL、加载 Redis Lua 或重启 A OpenSIPS。为避免 A 半发布,已将 A `/etc/opensips/opensips.cfg``/etc/opensips/lisglosips_hotpath.lua` 恢复到该备份,恢复后 `opensips -C` 通过且 `opensips` 服务 active。
- 修改文件:`prisma/migrations/20260624095000_number_library/migration.sql``prisma/migrations/20260624121000_business_prefix_gateway_rewrite/migration.sql``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:B `systemctl start lisglosips-backup.service`、备份目录内 `sha256sum -c SHA256SUMS`、创建 release 并覆盖构建产物、`prisma generate``prisma migrate resolve --rolled-back 20260624095000_number_library`、迁移账号执行补齐 DDL、`prisma migrate resolve --applied 20260624095000_number_library``prisma migrate resolve --applied 20260624121000_business_prefix_gateway_rewrite``prisma migrate status``node scripts/phase2-gateway-migration.mjs --json``node scripts/phase2-gateway-migration.mjs --apply --json``systemctl restart lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher`、B `lisglosips-release-preflight.sh`A `opensips -C -f /etc/opensips/opensips.cfg`、恢复 A phase2 备份。
- 验证结果:本地 `prisma validate` 使用占位 `DATABASE_URL` 通过;B MySQL/Redis 备份哈希通过;B Prisma status 为 `Database schema is up to date!`B API live/ready 与 HTTPS ready 返回 `ok`config-publisher 日志显示 schemaVersion 2 快照发布成功,manifest 包含 `businessPrefixCount``customerGatewayIpCount``customerGatewayCallerPrefixCount`、号码库与屏蔽地区统计;B preflight ok。A 候选配置语法检查曾通过;A 恢复备份后语法检查通过,OpenSIPS active。B 在最后阶段再次 ping/SSH 超时,A/B/T 实呼未执行。
- 回滚方式:B 应用可执行 `infra/server-b/s30/lisglosips-release-rollback.sh /opt/lisglosips/releases/s33-active-calls-20260623094000` 或手动将 `/opt/lisglosips/current` 指回该 release 并重启 API/CDR/Recording/Config Publisher。数据库已执行向前兼容新增表/列,优先用新页面/API 修正配置;如必须恢复,使用 `/data/backups/mysql/20260624T055810Z` 在隔离环境验证后恢复。Redis 可切回上一配置版本。A 侧已恢复到 `/var/backups/lisglosips-phase2/20260624142935`,未重启进入新热路径。
- 未解决问题:B Tailscale/主机网络不稳定,发布后再次不可达;A Redis URL 占位符问题需在后续发布脚本中修正,不能直接部署模板;尚未将 Lua SHA 加载到 RedisA OpenSIPS 未重启到新热路径;A/B/T 端到端呼叫、强制挂断、话单详情字段验证未执行;真实 80 万手机号段仍未导入。
- 下一任务:先恢复并稳定 B 连通性;确认 B current、服务、Redis 配置版本仍正常;读取受控 Redis URL 并安全替换 A cfg 占位符,加载 Lua SCRIPT 到 Redis`opensips -C` 通过后重启 A OpenSIPS;再执行 T 实呼和当前通话/话单验收。
### 2026-06-24 13:40 - 需求修改/二期变更:业务前缀重构第 9 步三机发布前验收
- 状态:已阻塞;未执行数据库迁移,未部署 release,未发布 Redis,未替换 A OpenSIPS,未重启服务
- 操作服务器:本地、A、T 只读探测;B 未连通
- 完成内容:按第 9 步 Runbook 先复核影响、风险与回滚点;确认本步骤会涉及 B MySQL/Redis 备份、Prisma migration、旧客户网关迁移 dry-run/apply、Redis 配置发布、A OpenSIPS/Lua 语法检查和 A/B/T 实呼验收。执行三机连通性检查时,A `100.90.90.90` 与 T `100.93.185.30` SSH 可达且 OpenSIPS activeB `100.90.90.91` 从开发机 SSH 超时,开发机 `Test-NetConnection` 显示 Ping 与 TCP 22 失败;从 A、T 到 B 的 ping 也 100% 丢包,`nc -vz 100.90.90.91 22` 超时。由于 B 是数据库、Redis、API/Worker 与 release 目标机,未满足安全发布前提,停止执行。
- 修改文件:`IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`ssh -F .codex-private/ssh/config lisglosips-a "hostname; whoami; systemctl is-active opensips || true"``ssh -F .codex-private/ssh/config lisglosips-t "hostname; whoami; systemctl is-active opensips || true"``Test-NetConnection 100.90.90.91 -Port 22``ping 100.90.90.91 -n 4`、从 A/T 执行 `ping -c 2 -W 2 100.90.90.91``nc -vz -w 3 100.90.90.91 22`
- 验证结果:A/T SSH 与 OpenSIPS 状态正常;B 当前不可达,无法记录 B current release、无法执行 S30 备份、无法执行 Prisma migration dry-run、无法做 Redis 配置发布与 A/B/T 端到端验收。
- 回滚方式:本次未对服务器做写操作,无需远端回滚。恢复 B 连通后,从第 9 步开头重新执行:先记录 B release/migration/Redis 版本,再执行 S30 MySQL/Redis 备份和恢复校验,然后部署新 release 到独立目录并 dry-run 迁移。
- 未解决问题:需要恢复 B 的 Tailscale/主机网络/SSH 可达性;恢复后需确认 B 上 `lisglosips@api``lisglosips@cdr-worker``lisglosips@recording-worker``lisglosips@config-publisher`、MySQL、Redis 状态,再继续第 9 步。
- 下一任务:恢复 B 可达性后重新执行第 9 步发布前验收;如需现场处理 B,优先通过 KVM/控制台检查电源、Tailscale、nftables、sshd 与系统状态。
### 2026-06-24 13:25 - 需求修改/二期变更:业务前缀重构第 8 步旧数据迁移与发布前验收准备
- 状态:已完成本地代码与 Runbook;未执行数据库迁移,未连接服务器,未发布 A/B,未重启服务
- 操作服务器:本地
- 完成内容:新增 `scripts/phase2-gateway-migration.mjs`,用于审计和安全回填旧客户网关数据。脚本默认 dry-run,不写库;只有显式 `--apply` 才写入。自动迁移仅允许“每个客户网关恰好 1 条启用旧策略、被叫任意、主叫任意或主叫前缀”的安全场景;多旧策略、无启用策略、旧被叫等值/前缀、旧主叫等值、新旧线路组冲突均列为人工 blockers。apply 时会写入 `customer_gateways.line_group_id/caller_match_mode/callee_match_mode`、补齐 `customer_gateway_ips``customer_gateway_caller_prefixes`,并写 `customer_gateway_config` outbox 触发配置发布。新增 `docs/PHASE2_BUSINESS_PREFIX_GATEWAY_MIGRATION_RUNBOOK.md`,覆盖 B migration、dry-run/apply、Redis 发布、A OpenSIPS 备份/语法检查/重启、A/B/T 实呼验收和回滚。
- 修改文件:`scripts/phase2-gateway-migration.mjs``docs/PHASE2_BUSINESS_PREFIX_GATEWAY_MIGRATION_RUNBOOK.md``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`node --check scripts/phase2-gateway-migration.mjs``node_modules\.bin\tsc.cmd -b tsconfig.build.json --pretty``node_modules\.bin\eslint.cmd .``node_modules\.bin\vitest.cmd run`、逐包 `node_modules\.bin\tsc.cmd -p ...``apps\web\node_modules\.bin\vite.CMD build`
- 验证结果:迁移脚本语法检查通过;typecheck 通过;lint 通过;全量 test 通过,25 个测试文件、86 条测试通过;所有 TypeScript package 构建通过;Web 使用 `apps/web` 自身 Vite 5 构建通过。
- 回滚方式:本地回滚可删除上述脚本/Runbook 并恢复状态文件。若未来在 B 执行 `--apply` 后需回滚,优先通过客户网关 API/页面修正新字段并重新发布 Redis;若必须恢复数据库,使用执行前 MySQL 备份在隔离环境验证后恢复。Redis 可切回上一 `cfg:previous_version`A OpenSIPS 可恢复发布前 cfg/Lua 备份并重新 `opensips -C` 后重启。
- 未解决问题:尚未在 B 执行 Prisma migration 和 dry-run;真实 blockers 数量未知;A 侧 `opensips -C``uac_replace_from` 实呼行为尚未验证;客户侧计费仍未切换到客户网关费率;业务前缀绑定仍需按真实客户号码规则人工配置。
- 下一任务:第 9 步本地/三机发布前总验收:先在 B 执行 dry-run 审计并处理 blockers,再按 Runbook 执行 migration、配置发布、A OpenSIPS 语法检查和 A/B/T 实呼验收;执行前必须再次确认备份和维护窗口。
### 2026-06-24 13:15 - 需求修改/二期变更:业务前缀重构第 7 步 CDR Stream 与入库链路
- 状态:已完成本地代码;未执行数据库迁移,未发布服务器,未重启服务
- 操作服务器:本地
- 完成内容:CDR Redis Stream 事件契约新增 `raw_callee``business_prefix_id``business_prefix``landing_caller``landing_callee``normalized_callee`,发布端可显式写入这些字段,解析端兼容旧消息缺字段。CDR Worker 创建 `raw_cdrs` 时写入 `rawCallee``businessPrefixId``businessPrefix``landingCaller``landingCallee`,并将 `normalized_callee` 保留在 payload 中。话单 API DTO 透出原始被叫、业务前缀、落地主叫、落地被叫;Web 话单详情弹窗的链路信息区展示这些字段,列表列宽不增加。
- 修改文件:`packages/redis/src/cdr-stream.ts``packages/redis/src/cdr-stream.spec.ts``apps/worker-cdr/src/rating.ts``apps/worker-cdr/src/rating.spec.ts``apps/api/src/modules/cdrs/cdrs.repository.ts``apps/api/src/modules/cdrs/cdrs.service.spec.ts``apps/web/src/App.jsx``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`node_modules\.bin\vitest.cmd run packages/redis/src/cdr-stream.spec.ts apps/worker-cdr/src/rating.spec.ts apps/api/src/modules/cdrs/cdrs.service.spec.ts``node_modules\.bin\tsc.cmd -b tsconfig.build.json --pretty``node_modules\.bin\eslint.cmd .``node_modules\.bin\vitest.cmd run`、逐包 `node_modules\.bin\tsc.cmd -p ...``apps\web\node_modules\.bin\vite.CMD build`
- 验证结果:定向测试通过,3 个测试文件、13 条测试通过;typecheck 通过;lint 通过;全量 test 通过,25 个测试文件、86 条测试通过;所有 TypeScript package 构建通过;Web 使用 `apps/web` 自身 Vite 5 构建通过。
- 回滚方式:恢复上述 Stream/Worker/CDR API/Web/状态文件即可;若未来已发布并产生新字段 CDR,回滚旧 Worker 前需确认旧版本能忽略 Stream 中额外字段,或先停止 A 侧新 Lua XADD;数据库已含兼容 nullable 字段,本次无额外 migration。
- 未解决问题:真实 B 环境仍需执行第 1 步 migration 后才能写入新 `raw_cdrs` 字段;A/B/T 尚未发布验收;客户侧计费仍未切换到客户网关费率;旧客户网关策略迁移脚本仍待实现;OpenSIPS `uac_replace_from` 行为仍需实呼验证。
- 下一任务:第 8 步旧数据迁移与发布前验收准备:迁移现有客户网关单 IP/旧策略到新表和单线路组字段,准备 B migration、配置发布、A OpenSIPS 语法检查与 A/B/T 实呼验收脚本。
### 2026-06-24 13:05 - 需求修改/二期变更:业务前缀重构第 6 步 OpenSIPS/Lua 热路径本地改造
- 状态:已完成本地代码;未连接服务器,未发布 A/B,未重启 OpenSIPS,未执行 `opensips -C`
- 操作服务器:本地
- 完成内容:重写 S28 热路径 Lua,使其优先读取 `auth:ip:{source}:gateways` 候选客户网关列表,兼容旧 `auth:ip:{source}` 单网关;按客户网关状态、客户状态、主叫前缀规则、被叫业务前缀规则匹配客户网关;被叫命中业务前缀时剥离前缀并输出真实被叫,同时保留原始被叫、业务前缀 ID/值、号码库归一化号码。线路组选择继续按同组落地网关优先级和地区屏蔽跳过逻辑执行;落地网关命中后按 `landingCalleePrefix` 生成落地被叫,按 `callerRewritePool` 权重和 Call-ID 确定性哈希选择落地指定主叫。保留旧 `customer_gateway_policies` 兼容分支,供未迁移客户网关继续路由。OpenSIPS S28 模板加载 `uac.so`,更新 Lua SHA,接收 24 个热路径返回字段,转发前改写 `$ru` 为落地被叫,并在需要时通过 `uac_replace_from` 改写 From;成功/失败 CDR XADD 追加 `raw_callee``business_prefix_id``business_prefix``landing_caller``landing_callee``normalized_callee`
- 修改文件:`infra/server-a/s28/lisglosips_hotpath.lua``infra/server-a/s28/opensips.cfg``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`node_modules\.bin\tsc.cmd -b tsconfig.build.json --pretty``node_modules\.bin\eslint.cmd .``node_modules\.bin\vitest.cmd run`、逐包 `node_modules\.bin\tsc.cmd -p ...``apps\web\node_modules\.bin\vite.CMD build`、Node 静态校验 Lua SHA 与 OpenSIPS AVP 索引。
- 验证结果:typecheck 通过;lint 通过;全量 test 通过,25 个测试文件、86 条测试通过;所有 TypeScript package 构建通过;Web 使用 `apps/web` 自身 Vite 5 构建通过;静态校验确认 `opensips.cfg` 中 EVALSHA 与当前 Lua SHA `0b7ee595cdcad2f38a670dd8ff3553387820039e` 一致,`s28_hotpath` AVP 映射覆盖 0-23。当前本地无 OpenSIPS/Lua 运行时,尚未执行 `opensips -C -f /etc/opensips/opensips.cfg`,发布到 A 前必须补做。
- 回滚方式:本地回滚可恢复 `infra/server-a/s28/lisglosips_hotpath.lua``infra/server-a/s28/opensips.cfg` 与状态文件。若未来已发布到 A,回滚前先停止变更窗口,恢复发布前备份的 `/etc/opensips/opensips.cfg``/etc/opensips/lisglosips_hotpath.lua`,重新加载旧 Lua SHA,执行 `opensips -C -f /etc/opensips/opensips.cfg` 通过后重启 OpenSIPS;B 侧 Redis 可切回上一配置版本。
- 未解决问题:真实 A 机语法检查和重启验收尚未执行;`uac_replace_from` 的最终行为需在 A/B/T 呼叫中确认;CDR Worker 目前仍未解析并入库 `raw_callee``business_prefix_id``business_prefix``landing_caller``landing_callee``normalized_callee` 新字段;客户侧计费仍未切换到客户网关费率;旧客户网关策略迁移脚本仍待实现。
- 下一任务:第 7 步改造 CDR Stream/Worker 入库链路,解析并写入业务前缀与落地主被叫字段,同时准备 A/B/T 端到端验收脚本。
### 2026-06-24 12:55 - 需求修改/二期变更:业务前缀重构第 5 步 Redis 配置发布结构
- 状态:已完成本地代码;未执行数据库迁移,未发布服务器,未重启服务,未写真实 Redis
- 操作服务器:本地
- 完成内容:`worker-config-publisher` 快照清单升级为 `schemaVersion: 2`,新增业务前缀数量、客户网关 IP 数、客户网关业务前缀数、客户网关主叫前缀数、落地指定主叫池数量等统计;配置快照新增 `businessPrefixes`,客户网关快照新增多 IP、单线路组、客户侧计费、主叫前缀规则、被叫业务前缀规则,落地网关快照新增 `landingCalleePrefix``callerRewritePool`。Redis 写入新增 `business_prefix:*``business_prefix_value:*``business_prefixes``auth:ip:*:gateways``customer_gateway:*:caller_prefixes``customer_gateway:*:business_prefixes``customer_gateway:*:line_group``vendor_gateway:*:caller_rewrite_pool` 等热路径 key,同时保留旧 `auth:ip:*`、旧客户策略列表和旧落地前缀规则兼容输出。业务前缀 CRUD 增删改启停时新增 `business_prefix_config` outbox,发布器纳入该 aggregate type。
- 修改文件:`apps/worker-config-publisher/src/publisher.ts``apps/worker-config-publisher/src/publisher.spec.ts``apps/api/src/modules/business-prefixes/business-prefixes.repository.ts``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件;执行 `prisma generate` 重新生成本地 Prisma Client。
- 执行的关键命令:`node_modules\.bin\prisma.cmd format``node_modules\.bin\prisma.cmd generate``node_modules\.bin\vitest.cmd run apps/worker-config-publisher/src/publisher.spec.ts``node_modules\.bin\tsc.cmd -b tsconfig.build.json --pretty``node_modules\.bin\eslint.cmd .``node_modules\.bin\vitest.cmd run`、逐包 `node_modules\.bin\tsc.cmd -p ...``apps\web\node_modules\.bin\vite.CMD build`
- 验证结果:Prisma format/generate 通过;发布器定向测试通过,1 条测试通过;typecheck 通过;lint 通过;全量 test 通过,25 个测试文件、86 条测试通过;所有 TypeScript package 构建通过;Web 使用 `apps/web` 自身 Vite 5 构建通过。根级 `node scripts/pnpm-run.mjs typecheck/build` 因当前 shell 找不到 `pnpm``spawnSync pnpm ENOENT`,已用本地 `.cmd` 可执行文件完成等效验证。
- 回滚方式:恢复上述发布器、业务前缀仓储和状态文件即可;若未来已经发布到 B 并产生 `business_prefix_config` outbox,可停止 config-publisher 后将相关 outbox 标为已处理或恢复发布前数据库备份,再切回旧 release。Redis 活跃配置可通过 `CONFIG_PREVIOUS_VERSION_KEY` 回滚到上一版本;本次未对服务器 Redis 进行写入。
- 未解决问题:真实 B 环境仍需先执行第 1 步 migration 后才能运行新发布器;OpenSIPS/Lua 尚未读取新 Redis key,尚未完成业务前缀剥离、客户网关候选匹配、客户侧计费、落地被叫加前缀和指定主叫权重选择;CDR 写入 raw callee/business prefix/landing caller/landing callee 与 A/B/T 验收仍待后续步骤。
- 下一任务:第 6 步改造 OpenSIPS/Lua 热路径:按 IP 候选客户网关、主叫前缀和被叫业务前缀匹配客户网关,并输出剥离后的真实被叫号码。
### 2026-06-24 12:50 - 需求修改/二期变更:业务前缀重构第 4 步落地网关新改写字段
- 状态:已完成本地代码;未执行数据库迁移,未发布服务器,未重启服务
- 操作服务器:本地
- 完成内容:落地网关 API 增加 `landingCalleePrefix``callerRewritePool`;创建/更新时写入 `vendor_gateways.landing_callee_prefix``vendor_gateway_caller_rewrite_pool`,并继续保留旧 `vendor_gateway_prefix_rules` 表作为兼容字段。落地被叫前缀支持空值或英文/数字;指定主叫号码池支持多个号码、权重和启停状态,并校验重复号码。前端落地网关编辑页将旧“被叫前缀转换/主叫前缀转换”替换为“落地要求被叫前缀”和“落地要求指定主叫”,列表新增落地被叫前缀与指定主叫数展示;保存时调用真实 `PATCH /api/v2/vendor-gateways/:id`。顺带修正落地网关子表 ID 生成长度,避免 `vgfp_...``vgcr_...` 超过 32 字符字段长度。
- 修改文件:`apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts``apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts``apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts``apps/web/src/api.js``apps/web/src/App.jsx``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件;执行 `prisma generate` 重新生成本地 Prisma Client。
- 执行的关键命令:`node_modules\.bin\prisma.cmd generate``node_modules\.bin\tsc.cmd -b tsconfig.build.json --pretty false``node_modules\.bin\vitest.cmd run apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts``corepack pnpm@10.33.0 lint``corepack pnpm@10.33.0 build``corepack pnpm@10.33.0 test`
- 验证结果:Prisma generate 通过;typecheck 通过;落地网关 e2e 通过,4 条测试通过;lint 通过;workspace build 通过;全量 test 通过,25 个测试文件、86 条测试通过。
- 回滚方式:恢复上述落地网关 API/Web 文件和 `IMPLEMENTATION_STATUS.md` 即可;若只回滚第 4 步,可保留第 1-3 步。若未来已执行 migration 且已录入新落地改写数据,回滚前需导出 `vendor_gateways.landing_callee_prefix``vendor_gateway_caller_rewrite_pool`,确认没有生产配置依赖后再删列/删表或恢复备份。
- 未解决问题:当前前端仍只完善落地网关编辑路径,新增按钮仍是占位;真实 B 环境必须先执行第 1 步 migration 后才能使用本页面新字段;Redis 发布结构、OpenSIPS/Lua 热路径落地被叫加前缀/主叫按权重改写、CDR 写入 landing caller/callee 和 A/B/T 验收仍待后续步骤。
- 下一任务:第 5 步改造 Redis 配置发布与 Worker/热路径配置结构,先在本地生成新配置快照契约,再安排 OpenSIPS/Lua 处理顺序。
### 2026-06-24 12:35 - 需求修改/二期变更:业务前缀重构第 3 步客户网关新模型
- 状态:已完成本地代码;未执行数据库迁移,未发布服务器,未重启服务
- 操作服务器:本地
- 完成内容:客户网关 API 扩展为多 IP、单落地线路组、客户侧计费周期/周期费率、主叫匹配模式与前缀列表、被叫匹配模式与业务前缀多选;创建/更新时写入 `customer_gateway_ips``customer_gateway_caller_prefixes``customer_gateway_business_prefixes`,同时保留旧 `source_ip` 为首个 IP 兼容字段,旧 `customer_gateway_policies` 表和模块未删除。前端客户网关页面新增客户选择、多个 IP、SIP 域、落地线路组、客户费率、主叫规则和业务前缀多选;列表展示落地线路组、客户费率、主叫规则和被叫规则;旧“策略配置”入口已从客户网关列表隐藏。客户网关新增/编辑已接入真实 API。
- 修改文件:`apps/api/src/modules/customer-gateways/customer-gateways.repository.ts``apps/api/src/modules/customer-gateways/customer-gateways.service.ts``apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts``apps/web/src/api.js``apps/web/src/App.jsx``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件;执行 `prisma generate` 重新生成本地 Prisma Client。
- 执行的关键命令:`node_modules\.bin\prisma.cmd generate``node_modules\.bin\tsc.cmd -b tsconfig.build.json --pretty false``node_modules\.bin\vitest.cmd run apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts``corepack pnpm@10.33.0 lint``corepack pnpm@10.33.0 build``corepack pnpm@10.33.0 test`
- 验证结果:Prisma generate 通过;typecheck 通过;客户网关 e2e 通过,5 条测试通过;lint 通过;workspace build 通过;全量 test 通过,25 个测试文件、86 条测试通过。
- 回滚方式:恢复上述客户网关 API/Web 文件和 `IMPLEMENTATION_STATUS.md` 即可;若只回滚第 3 步,可保留第 1 步 Schema 与第 2 步业务前缀管理。本次未连接服务器、未执行 MySQL migration、未写 Redis、未重启 OpenSIPS/RTPEngine/Worker/Web。
- 未解决问题:真实 B 环境必须先执行第 1 步 migration 后才能使用本页面新字段;旧客户网关策略数据尚未迁移到 `customer_gateways.line_group_id` 与新匹配表;落地网关“落地要求被叫前缀/指定主叫池”、Redis 发布结构、OpenSIPS/Lua 热路径号码剥离与落地改写、CDR 写入链路和 A/B/T 验收仍待后续步骤。
- 下一任务:第 4 步改造落地网关 API/Web:用 `landing_callee_prefix``vendor_gateway_caller_rewrite_pool` 替代旧主被叫前缀转换规则。
### 2026-06-24 12:20 - 需求修改/二期变更:业务前缀重构第 2 步 API 与 Web 菜单
- 状态:已完成本地代码;未执行数据库迁移,未发布服务器,未重启服务
- 操作服务器:本地
- 完成内容:新增 `BusinessPrefixesModule`,提供 `GET/POST/PATCH/DELETE /api/v2/business-prefixes` 以及启用/禁用接口;权限复用 `customer_gateways.view/manage`,审计模块记录 business-prefixes 创建、修改、启停、删除。前端新增“业务前缀管理”菜单和页面,支持关键字/状态筛选、新增、编辑、启用/禁用确认、删除确认,删除按钮使用红色警示色;前端 API Client 增加业务前缀相关方法和错误文案。业务前缀删除时若已被客户网关引用会返回 `BUSINESS_PREFIX_IN_USE`
- 修改文件:`apps/api/src/modules/business-prefixes/``apps/api/src/modules/app.module.ts``apps/web/src/api.js``apps/web/src/App.jsx``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件;执行 `prisma generate` 重新生成本地 Prisma Client。
- 执行的关键命令:`node_modules\.bin\prisma.cmd generate``node_modules\.bin\tsc.cmd -b tsconfig.build.json --pretty false``corepack pnpm@10.33.0 lint``node_modules\.bin\vitest.cmd run packages/database/src/schema-contract.spec.ts``corepack pnpm@10.33.0 build``corepack pnpm@10.33.0 test`
- 验证结果:Prisma generate 通过;typecheck 通过;lint 通过;database contract 定向测试通过;workspace build 通过;全量 test 通过,25 个测试文件、86 条测试通过。
- 回滚方式:恢复 `apps/api/src/modules/business-prefixes/``apps/api/src/modules/app.module.ts``apps/web/src/api.js``apps/web/src/App.jsx``IMPLEMENTATION_STATUS.md`,并保留/恢复第 1 步 Schema 取决于是否继续二期;本次未连接服务器、未执行 MySQL migration、未写 Redis、未重启 OpenSIPS/RTPEngine/Worker/Web。
- 未解决问题:当前业务前缀页面依赖第 1 步 migration 实际执行后才可在真实 B API 使用;客户网关多 IP、单线路组、客户侧费率、业务前缀多选、主叫前缀规则、落地网关新改写字段、Redis 发布结构、OpenSIPS/Lua 热路径和 A/B/T 验收仍待后续步骤。
- 下一任务:第 3 步改造客户网关 API/Web:支持多 IP、单落地线路组、客户侧费率、业务前缀多选和主叫前缀规则,同时设计旧策略兼容/迁移方式。
### 2026-06-24 12:05 - 需求修改/二期变更:业务前缀重构第 1 步本地数据模型
- 状态:已完成本地代码;未执行数据库迁移,未发布服务器,未重启服务
- 操作服务器:本地
- 完成内容:按兼容迁移方式新增业务前缀、客户网关多 IP、客户网关单落地线路组、客户侧计费周期/费率、客户网关主叫/被叫匹配模式、客户网关业务前缀关联、客户网关主叫前缀列表、落地网关“落地要求被叫前缀”、落地网关指定主叫号码池和权重、话单 raw callee/business prefix/landing caller/landing callee 等二期字段。保留旧 `customer_gateway_policies``vendor_gateway_prefix_rules`,用于后续 API/Web/OpenSIPS 分阶段切换。
- 修改文件:`prisma/schema.prisma``prisma/migrations/20260624121000_business_prefix_gateway_rewrite/migration.sql``packages/database/src/schema-contract.spec.ts``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件;继续使用本地仓库依赖中的 Prisma `6.19.0`、Vitest `4.0.14`
- 执行的关键命令:`node_modules\.bin\prisma.cmd format``$env:DATABASE_URL='mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; node_modules\.bin\prisma.cmd validate``node_modules\.bin\vitest.cmd run packages/database/src/schema-contract.spec.ts`
- 验证结果:Prisma format 通过;Prisma validate 通过;数据库 contract 定向测试通过,1 个测试文件、6 条测试通过。全量 lint/typecheck/test/build 尚未运行,因为本步只改 Schema、迁移与 contract,下一步 API/Web 改动后再跑全量验证。
- 回滚方式:恢复 `prisma/schema.prisma`、删除 `prisma/migrations/20260624121000_business_prefix_gateway_rewrite/` 并恢复 `packages/database/src/schema-contract.spec.ts``IMPLEMENTATION_STATUS.md` 即可;本次未连接服务器、未执行 MySQL migration、未写 Redis、未重启 OpenSIPS/RTPEngine/Worker/Web。
- 未解决问题:业务前缀管理 API/Web、客户网关新表单与新 API、旧策略迁移脚本、落地网关新字段 API/Web、Redis 发布结构、OpenSIPS/Lua 热路径号码剥离与落地改写、CDR 写入链路、A/B/T 端到端验收仍待后续步骤实施。
- 下一任务:第 2 步实现“业务前缀管理” API 与 Web 菜单,继续保持本地开发,不直接执行 B MySQL migration。
### 2026-06-24 11:58 - 需求修改/二期变更:业务前缀、客户网关匹配和落地号码改写重构需求评估
- 状态:已完成需求文档更新;未改代码,未执行数据库迁移,未发布服务器
- 操作服务器:本地
- 完成内容:将新增“业务前缀管理”菜单、客户网关多 IP、客户网关单线路组、客户网关客户侧费率、主叫前缀/被叫业务前缀匹配规则、真实被叫号码语义、落地要求被叫前缀、落地要求指定主叫号码池和权重选择等需求写入 `SOFTSWITCH_PLATFORM_DESIGN_V2.md`。明确该需求会替代旧客户网关多路由策略模型和旧落地网关主被叫前缀转换规则,必须分阶段迁移,不能两套热路径并存。补充数据模型建议、Redis 热路径结构、OpenSIPS/Lua 处理顺序、CDR 字段、失败原因、风险约束和 10 步实施拆分。
- 修改文件:`SOFTSWITCH_PLATFORM_DESIGN_V2.md``IMPLEMENTATION_STATUS.md`
- 安装软件及固定版本:未安装新软件。
- 执行的关键命令:`rg` 检查现有客户网关策略、落地前缀转换、号码库、OpenSIPS 热路径和文档位置;文档更新后用关键字复核“业务前缀管理”“customer_gateway_ips”“landing_callee_prefix”“vendor_gateway_caller_rewrite_pool”“BUSINESS_PREFIX_NOT_MATCHED”等条目。
- 验证结果:本次为 Markdown 需求文档更新,未运行 typecheck/lint/test/build;未连接服务器,未执行 Prisma migration,未写 Redis,未重启 OpenSIPS。
- 回滚方式:恢复 `SOFTSWITCH_PLATFORM_DESIGN_V2.md``IMPLEMENTATION_STATUS.md` 本次文档改动即可;不涉及 B release、MySQL、Redis、OpenSIPS、RTPEngine、Worker 或 Web 回滚。
- 未解决问题:实施前仍需确认业务前缀允许字符和长度、是否全局唯一、同一 IP 下多个客户网关规则冲突策略、客户侧费率是否立即替代 S23 最小计费逻辑、旧客户网关多策略如何迁移、落地指定主叫权重选择是否要求严格随机或按 Call-ID 可复现。
- 下一任务:建议先做第 1 步“需求冻结与冲突口径确认”,随后单独开数据库建模/migration,不要直接改 OpenSIPS 热路径。
### 2026-06-24 11:41 - 需求修改/二期变更:号码库第 6、7 步本地实现
- 状态:已完成本地代码;准备提交 Git;未发布到 B/A,未执行数据库迁移,未重启服务
+115 -3
View File
@@ -22,10 +22,11 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上
| --- | --- |
| 概览 Dashboard | 通话、接通率、消费、成本、毛利、注册、节点、异常网关、质检指标 |
| 客户管理 | 客户新增、编辑、启停、余额、授信、充值、网关数量、受控删除 |
| 客户网关管理 | IP/SIP 注册认证、启停、删除、多个策略、优先级、主被叫匹配、线路组绑定 |
| 业务前缀管理 | 管理客户呼入被叫业务前缀,热路径先按前缀识别业务并剥离真实被叫 |
| 客户网关管理 | 多接入 IP/SIP 注册认证、启停、删除、单线路组绑定、主叫前缀/业务前缀匹配、客户侧计费周期与费率 |
| 充值记录 | 客户充值、供应商充值、余额前后值、操作人、备注;仅记录页面人工充值,不记录话单消费扣费 |
| 供应商管理 | 供应商账户、余额、授信、充值、落地网关数量、受控删除 |
| 落地网关管理 | 认证、并发、CPS、禁呼时段、编码、号码转换、计费周期、费率、启停、删除 |
| 落地网关管理 | 认证、并发、CPS、禁呼时段、编码、落地要求被叫前缀、落地要求指定主叫号码池、计费周期、费率、启停、删除 |
| 落地线路组 | 线路组、组内网关、优先级、并发汇总、使用客户网关数量、受控删除 |
| 号码库 | 手机号码库、城市区号、运营商号码段规则;为话单归属地/运营商和落地网关屏蔽地区提供基础数据 |
| 当前通话 | OpenSIPS Dialog 实时列表、呼叫方 IP、落地 IP、自动刷新、强制挂断 |
@@ -1421,9 +1422,120 @@ sudo nohup runuser -u nobody -- /usr/bin/python3 /opt/lisglosips-s28/lisglosips-
- `POST /api/v2/number-library/carrier-prefix-rules/import`
- 权限新增:
- `number_library.view`
- `number_library.manage`
- `number_library.manage`
- 当前导入接口采用单批 `items` upsert,单次限制 1000 条,适合脚本/后台任务分批调用。80 万号段的真实文件导入、进度表、失败明细和 Redis 发布仍属于后续步骤,不在本次第 1、2 步中直接执行。
#### 25.5.10 业务前缀、客户网关匹配和落地号码改写重构
本需求为 S30 后高优先级二期变更,会重塑客户接入识别、客户侧计费、热路径路由和 CDR 号码语义。实现时不得在旧“客户网关多路由策略”模型上继续叠加临时逻辑,必须设计清楚迁移边界,避免同一通呼叫同时被旧策略和新规则命中。
新增“业务前缀管理”菜单:
- 业务前缀是客户呼入时附加在被叫号码前面的业务识别前缀,用于区分客户业务、费率和落地线路组。
- 示例:客户真实想呼叫 `18822190000`,平台收到的 SIP Request-URI/User 或 To 中被叫为 `67118822190000`,其中 `671` 为业务前缀,真实被叫号码为 `18822190000`
- 业务前缀需要平台统一维护,客户网关只能从“业务前缀管理”中多选可用前缀,不能在客户网关中随意输入未登记的业务前缀。
- 业务前缀字段建议至少包括:`id``prefix``name``description``status``priority``created_at``updated_at``deleted_at``prefix` 允许英文和阿拉伯数字,必须唯一;匹配时按最长前缀优先,再按 `priority`
呼叫识别与号码语义:
- 平台收到 SIP 呼叫后,先用来源 IP 命中候选客户网关 IP 列表,再用客户网关的主叫前缀规则和被叫业务前缀规则确定唯一客户网关。
- 被叫业务前缀匹配成功后,热路径必须剥离业务前缀,后续当前通话、CDR、计费、号码库归属地/运营商识别、地区屏蔽和落地侧转发都以“剥离后的真实被叫号码”为准。
- 当前通话和话单中写入的被叫号码必须是客户真实想呼叫的号码,即“客户送入被叫 - 被叫业务前缀”。如需排障,可额外保存原始被叫号码和命中的业务前缀。
- 建议新增 CDR 字段:`raw_callee``business_prefix_id``business_prefix``normalized_callee`。其中 `callee` 继续表示业务剥离后的真实被叫,避免影响现有话单展示语义。
客户网关配置管理调整:
- 一个客户网关可配置多个接入 IP。现有 `customer_gateways.source_ip` 单值字段应迁移为子表,例如 `customer_gateway_ips`,字段包含 `id``customer_gateway_id``source_ip``status`、审计列和软删除列。
- 删除原先“路由策略”模型。一个客户网关只对应一个落地线路组,不再维护多策略、优先级、每策略线路组绑定。
- 客户网关可配置客户侧费率,包含计费周期和周期内费率,可参考落地网关现有 `billing_cycle_sec``cycle_rate`。建议字段为 `billing_cycle_sec``cycle_rate`、必要时保留 `currency``effective_from/effective_to`
- 客户网关被叫前缀匹配规则:
- `ANY`:任意号码,不剥离业务前缀。
- `BUSINESS_PREFIXES`:从业务前缀管理中多选;匹配命中后剥离该业务前缀。
- 若多个业务前缀可命中同一个被叫,按最长前缀优先。
- 客户网关主叫前缀匹配规则:
- `ANY`:任意主叫。
- `PREFIXES`:可配置多个自定义主叫前缀。
- 主叫前缀只是匹配条件,不改写主叫。
- 若同一 IP 下多个客户网关的主叫/被叫规则都可命中同一呼叫,应在保存配置时阻止冲突,或者明确热路径选择规则。建议优先在 API 保存时做冲突检测,避免呼叫时不确定。
落地网关配置调整:
- 废弃现有“被叫前缀转换规则”列表,改为“落地要求被叫前缀”单文本框。
- 可为空。
- 可写任意英文或阿拉伯字符。
- 呼叫送到该落地网关时,在真实被叫号码前追加该前缀,再转发给落地网关。
- 示例:真实被叫 `18822190000`,落地要求被叫前缀为 `00`,则送落地的被叫为 `0018822190000`
- 废弃现有“主叫前缀转换规则”列表,改为“落地要求指定主叫”号码池。
- 每个号码可为空或写任意英文/阿拉伯字符;为空表示保持原主叫。
- 支持添加多个号码并指定权重。
- 呼叫送到该落地网关时,按权重概率选择一个指定主叫,将主叫改写为该号码。
- 建议建模为 `vendor_gateway_caller_rewrite_pool`,字段包括 `id``vendor_gateway_id``caller``weight``status`、审计列。热路径按确定性随机或可复现哈希选择,避免同一重试请求选择结果抖动。
Redis 热路径与 OpenSIPS/Lua 影响:
- Config Publisher 需要发布:
- `cfg:v:{version}:business_prefix:{prefix}` 或按客户网关聚合的业务前缀匹配结构。
- `cfg:v:{version}:customer_gateway_ip:{sourceIp}` -> 候选客户网关列表,而不是单个网关 ID。
- `cfg:v:{version}:customer_gateway:{id}` 中包含单线路组、客户侧费率、主叫前缀规则、业务前缀规则。
- `cfg:v:{version}:vendor_gateway:{id}` 中包含 `landingCalleePrefix`
- `cfg:v:{version}:vendor_gateway:{id}:caller_rewrite_pool` 中包含指定主叫与权重。
- OpenSIPS/Lua 需要调整顺序:
1. 读取来源 IP 候选客户网关列表。
2. 对每个候选客户网关检查启用状态、客户状态、主叫前缀规则、被叫业务前缀规则。
3. 选出唯一客户网关,得到客户、客户侧费率、落地线路组、命中的业务前缀和剥离后的真实被叫。
4. 用真实被叫做号码库归属地/运营商识别和地区屏蔽路由。
5. 选择落地网关后,按落地网关配置追加落地要求被叫前缀,并按权重选择落地要求指定主叫。
6. 转发给落地前,写入 Dialog 变量:原始被叫、真实被叫、业务前缀、客户网关、线路组、落地网关、落地侧最终主叫/被叫。
7. 当前通话与 CDR 使用真实被叫作为主展示字段;必要时详情显示原始被叫和落地侧最终被叫。
- 失败原因建议新增:
- `CUSTOMER_GATEWAY_IP_MISSING`
- `CUSTOMER_GATEWAY_RULE_CONFLICT`
- `CUSTOMER_GATEWAY_RULE_MISSING`
- `BUSINESS_PREFIX_NOT_MATCHED`
- `LINE_GROUP_MISSING`
- `VENDOR_CALLER_REWRITE_POOL_INVALID`
数据库和迁移影响:
- 本需求涉及 Prisma Schema 和 MySQL migration,执行前必须备份 B MySQL,并明确回滚点。
- 建议新增/调整:
- `business_prefixes`
- `customer_gateway_ips`
- `customer_gateway_business_prefixes`
- `customer_gateway_caller_prefixes`
- `customer_gateways.line_group_id`
- `customer_gateways.billing_cycle_sec`
- `customer_gateways.cycle_rate`
- `vendor_gateways.landing_callee_prefix`
- `vendor_gateway_caller_rewrite_pool`
- `raw_cdrs.raw_callee`
- `raw_cdrs.business_prefix_id`
- `raw_cdrs.business_prefix`
- `raw_cdrs.landing_caller`
- `raw_cdrs.landing_callee`
- 旧表 `customer_gateway_policies``vendor_gateway_prefix_rules` 不应立即物理删除。建议第一阶段保留表结构但停止新 UI/API 使用,迁移旧数据到新结构并标记弃用;确认 B/A/T 验收后再计划清理。
实施拆分建议:
1. 需求冻结与冲突口径确认:确认业务前缀允许字符、最长长度、是否全局唯一;确认同一 IP 下多客户网关冲突处理;确认客户网关费率是否立即替代 S23 最小计费逻辑;确认旧策略数据迁移规则。
2. 数据建模与 migration:新增业务前缀、客户网关多 IP、客户网关业务前缀关联、主叫前缀集合、客户网关单线路组和费率、落地指定主叫池、CDR 原始/真实/落地号码快照字段。执行前备份 B MySQL;本地先跑 Prisma validate/generate/schema contract。
3. API 改造:新增业务前缀 CRUD;改造客户网关 API 支持多 IP、单线路组、客户侧费率、主叫前缀集合、业务前缀多选;改造落地网关 API 支持落地要求被叫前缀和指定主叫池;停用或隐藏旧客户网关策略 API 的新增入口。
4. Web 改造:新增“业务前缀管理”菜单;客户网关页面改为多 IP、单线路组、费率、主叫前缀/业务前缀规则;落地网关页面替换旧主被叫前缀转换 UI。
5. Config Publisher 改造:发布候选客户网关列表、业务前缀匹配结构、客户网关单线路组/费率、落地前缀和主叫池;保留上一版本 Redis 回滚。
6. OpenSIPS/Lua 热路径改造:实现多候选客户网关匹配、业务前缀剥离、真实被叫号码进入号码库/地区屏蔽、落地侧主被叫改写、失败原因和 Dialog 变量。
7. CDR Stream/Worker/API/WebCDR 事件和 raw CDR 入库保存原始被叫、真实被叫、业务前缀、落地侧主叫/被叫;当前通话和话单默认展示真实被叫,详情展示原始/落地号码。
8. 旧数据迁移与兼容:将现有单 IP 客户网关迁移到 `customer_gateway_ips`;将每个客户网关当前唯一有效策略迁移为单线路组和匹配规则。若一个客户网关存在多个有效策略,必须人工确认拆分为多个客户网关或选择保留规则。
9. 本地验证:typecheck、lint、test、build;新增热路径 Lua 单元/脚本级样例,覆盖 `67118822190000 -> 18822190000`、多 IP、多主叫前缀、业务前缀多选、落地被叫追加前缀、主叫按权重选择。
10. A/B/T 验收:发布前备份 B release/MySQL/Redis 和 A OpenSIPS/Lua;在 A 执行 `opensips -C -f`;T 构造至少三类呼叫:命中业务前缀、任意被叫、未命中业务前缀;验证当前通话、CDR、计费、线路组选择、落地侧最终主被叫。
风险与约束:
- 这是高风险路由核心改造,影响前端、API、Prisma Schema、Redis 热路径、OpenSIPS、CDR Worker、当前通话、话单中心和回归脚本。
- 数据库迁移、Redis 发布和 OpenSIPS 重启必须分阶段发布,不能一次性在生产式环境无回滚点切换。
- “客户网关多 IP + 主叫前缀 + 业务前缀”会带来配置冲突,API 必须提前检测,否则热路径可能选错账户和费率。
- 被叫业务前缀剥离会改变 CDR 主展示号码和计费/地区识别输入,历史话单解释必须保留原始被叫快照以便排障。
- 落地指定主叫按权重选择需要可追溯,CDR 必须保存最终落地主叫;否则供应商侧投诉无法复盘。
### 25.6 Codex 新会话启动模板
```text
+2
View File
@@ -9,6 +9,7 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
import { AuditModule } from './audit/audit.module.js';
import { ActiveCallsModule } from './active-calls/active-calls.module.js';
import { AuthModule } from './auth/auth.module.js';
import { BusinessPrefixesModule } from './business-prefixes/business-prefixes.module.js';
import { CdrsModule } from './cdrs/cdrs.module.js';
import { CustomerGatewayPoliciesModule } from './customer-gateway-policies/customer-gateway-policies.module.js';
import { CustomerGatewaysModule } from './customer-gateways/customer-gateways.module.js';
@@ -59,6 +60,7 @@ import { VendorsModule } from './vendors/vendors.module.js';
AuditModule,
AuthModule,
ActiveCallsModule,
BusinessPrefixesModule,
CdrsModule,
DashboardModule,
CustomersModule,
+6 -3
View File
@@ -23,7 +23,8 @@ class E2eAuthRepository implements AuthRepository {
failedLoginCount: 0,
lockedUntil: null,
requirePasswordChange: false,
roles: ['admin']
roles: ['admin'],
permissions: ['dashboard.view', 'customers.view']
};
}
@@ -32,7 +33,7 @@ class E2eAuthRepository implements AuthRepository {
}
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
return username === this.user.username ? { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] } : null;
}
async markLoginSuccess(): Promise<void> {
@@ -52,7 +53,7 @@ class E2eAuthRepository implements AuthRepository {
refreshTokenHash: input.refreshTokenHash,
expiresAt: input.expiresAt,
revokedAt: null,
user: { ...this.user, roles: [...this.user.roles] }
user: { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] }
};
this.sessions.set(session.id, session);
@@ -130,6 +131,7 @@ describe('LisgloSIPS Auth API', () => {
const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? '';
expect(login.body.accessToken).toBeTypeOf('string');
expect(login.body.user.permissions).toEqual(['dashboard.view', 'customers.view']);
expect(loginCookie).toContain('HttpOnly');
expect([...repo.sessions.values()][0].refreshTokenHash).toBe(sha256Token(decodeURIComponent(firstRefreshToken)));
@@ -137,6 +139,7 @@ describe('LisgloSIPS Auth API', () => {
const refreshCookie = refresh.headers['set-cookie'][0];
expect(refresh.body.accessToken).toBeTypeOf('string');
expect(refresh.body.user.permissions).toEqual(['dashboard.view', 'customers.view']);
expect([...repo.sessions.values()][0].revokedAt).toBeInstanceOf(Date);
await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', loginCookie).expect(401);
+58 -39
View File
@@ -1,5 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import crypto from 'node:crypto';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../database/prisma.service.js';
import type { AuthRepository, AuthSessionRecord, AuthUserRecord, CreateSessionInput } from './auth.types.js';
@@ -7,6 +8,45 @@ function sessionId(): string {
return `ses_${crypto.randomUUID().replaceAll('-', '')}`;
}
type AuthUserWithRoles = Prisma.UserGetPayload<{
include: {
userRoles: {
include: {
role: {
include: {
permissions: true;
};
};
};
};
};
}>;
function toAuthUserRecord(user: AuthUserWithRoles): AuthUserRecord {
const enabledRoles = user.userRoles.map((userRole) => userRole.role).filter((role) => role.status === 'ENABLED' && !role.deletedAt);
const permissions = new Set<string>();
for (const role of enabledRoles) {
for (const rolePermission of role.permissions) {
permissions.add(rolePermission.permissionId);
}
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
passwordHash: user.passwordHash,
passwordAlgo: user.passwordAlgo,
status: user.status,
failedLoginCount: user.failedLoginCount,
lockedUntil: user.lockedUntil,
requirePasswordChange: user.requirePasswordChange,
roles: enabledRoles.map((role) => role.name),
permissions: [...permissions].sort()
};
}
@Injectable()
export class PrismaAuthRepository implements AuthRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
@@ -17,7 +57,11 @@ export class PrismaAuthRepository implements AuthRepository {
include: {
userRoles: {
include: {
role: true
role: {
include: {
permissions: true
}
}
}
}
}
@@ -27,18 +71,7 @@ export class PrismaAuthRepository implements AuthRepository {
return null;
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
passwordHash: user.passwordHash,
passwordAlgo: user.passwordAlgo,
status: user.status,
failedLoginCount: user.failedLoginCount,
lockedUntil: user.lockedUntil,
requirePasswordChange: user.requirePasswordChange,
roles: user.userRoles.map((userRole) => userRole.role.name)
};
return toAuthUserRecord(user);
}
async markLoginSuccess(userId: string, ip?: string): Promise<void> {
@@ -79,7 +112,11 @@ export class PrismaAuthRepository implements AuthRepository {
include: {
userRoles: {
include: {
role: true
role: {
include: {
permissions: true
}
}
}
}
}
@@ -93,18 +130,7 @@ export class PrismaAuthRepository implements AuthRepository {
refreshTokenHash: session.refreshTokenHash,
expiresAt: session.expiresAt,
revokedAt: session.revokedAt,
user: {
id: session.user.id,
username: session.user.username,
displayName: session.user.displayName,
passwordHash: session.user.passwordHash,
passwordAlgo: session.user.passwordAlgo,
status: session.user.status,
failedLoginCount: session.user.failedLoginCount,
lockedUntil: session.user.lockedUntil,
requirePasswordChange: session.user.requirePasswordChange,
roles: session.user.userRoles.map((userRole) => userRole.role.name)
}
user: toAuthUserRecord(session.user)
};
}
@@ -116,7 +142,11 @@ export class PrismaAuthRepository implements AuthRepository {
include: {
userRoles: {
include: {
role: true
role: {
include: {
permissions: true
}
}
}
}
}
@@ -134,18 +164,7 @@ export class PrismaAuthRepository implements AuthRepository {
refreshTokenHash: session.refreshTokenHash,
expiresAt: session.expiresAt,
revokedAt: session.revokedAt,
user: {
id: session.user.id,
username: session.user.username,
displayName: session.user.displayName,
passwordHash: session.user.passwordHash,
passwordAlgo: session.user.passwordAlgo,
status: session.user.status,
failedLoginCount: session.user.failedLoginCount,
lockedUntil: session.user.lockedUntil,
requirePasswordChange: session.user.requirePasswordChange,
roles: session.user.userRoles.map((userRole) => userRole.role.name)
}
user: toAuthUserRecord(session.user)
};
}
@@ -20,7 +20,8 @@ class MemoryAuthRepository implements AuthRepository {
failedLoginCount: 0,
lockedUntil: null,
requirePasswordChange: false,
roles: ['admin']
roles: ['admin'],
permissions: ['dashboard.view', 'customers.view']
};
}
@@ -34,7 +35,7 @@ class MemoryAuthRepository implements AuthRepository {
}
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
return username === this.user.username ? { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] } : null;
}
async markLoginSuccess(_userId: string, _ip?: string): Promise<void> {
@@ -54,7 +55,7 @@ class MemoryAuthRepository implements AuthRepository {
refreshTokenHash: input.refreshTokenHash,
expiresAt: input.expiresAt,
revokedAt: null,
user: { ...this.user, roles: [...this.user.roles] }
user: { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] }
};
this.sessions.set(session.id, session);
@@ -112,6 +113,7 @@ describe('AuthService', () => {
const session = [...repo.sessions.values()][0];
expect(result.response.user.username).toBe('operator');
expect(result.response.user.permissions).toEqual(['dashboard.view', 'customers.view']);
expect(result.response.accessToken.split('.')).toHaveLength(3);
expect(session.refreshTokenHash).toBe(sha256Token(result.tokens.refreshToken));
expect(session.refreshTokenHash).not.toBe(result.tokens.refreshToken);
@@ -32,6 +32,7 @@ export interface AuthResponse {
username: string;
displayName: string;
roles: string[];
permissions: string[];
requirePasswordChange: boolean;
};
}
@@ -171,6 +172,7 @@ export class AuthService {
username: user.username,
displayName: user.displayName,
roles: user.roles,
permissions: user.permissions,
requirePasswordChange: user.requirePasswordChange
}
};
+1
View File
@@ -9,6 +9,7 @@ export interface AuthUserRecord {
lockedUntil: Date | null;
requirePasswordChange: boolean;
roles: string[];
permissions: string[];
}
export interface AuthSessionRecord {
@@ -0,0 +1,58 @@
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuditAction } from '../audit/audit.metadata.js';
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
import { BusinessPrefixesService } from './business-prefixes.service.js';
@ApiTags('business-prefixes')
@Controller('business-prefixes')
export class BusinessPrefixesController {
constructor(@Inject(BusinessPrefixesService) private readonly businessPrefixesService: BusinessPrefixesService) {}
@Get()
@RequirePermissions('customer_gateways.view')
list(@Query() query: unknown) {
return this.businessPrefixesService.list(query as never);
}
@Get(':id')
@RequirePermissions('customer_gateways.view')
get(@Param('id') id: string) {
return this.businessPrefixesService.get(id);
}
@Post()
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'create', objectType: 'business-prefix' })
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.create(body as never, currentUser?.id);
}
@Patch(':id')
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'update', objectType: 'business-prefix', objectIdParam: 'id' })
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.update(id, body as never, currentUser?.id);
}
@Post(':id/enable')
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'enable', objectType: 'business-prefix', objectIdParam: 'id' })
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.enable(id, currentUser?.id);
}
@Post(':id/disable')
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'disable', objectType: 'business-prefix', objectIdParam: 'id' })
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.disable(id, currentUser?.id);
}
@Delete(':id')
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'delete', objectType: 'business-prefix', objectIdParam: 'id' })
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.remove(id, currentUser?.id);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { BusinessPrefixesController } from './business-prefixes.controller.js';
import { BUSINESS_PREFIXES_REPOSITORY, PrismaBusinessPrefixesRepository } from './business-prefixes.repository.js';
import { BusinessPrefixesService } from './business-prefixes.service.js';
@Module({
controllers: [BusinessPrefixesController],
providers: [
BusinessPrefixesService,
{
provide: BUSINESS_PREFIXES_REPOSITORY,
useClass: PrismaBusinessPrefixesRepository
}
],
exports: [BusinessPrefixesService]
})
export class BusinessPrefixesModule {}
@@ -0,0 +1,217 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import crypto from 'node:crypto';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
export type BusinessPrefixStatus = 'ENABLED' | 'DISABLED';
export interface BusinessPrefixSummary {
id: string;
prefix: string;
name: string;
description: string | null;
priority: number;
status: BusinessPrefixStatus;
gatewayCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface CreateBusinessPrefixInput {
prefix: string;
name: string;
description?: string | null;
priority: number;
status?: BusinessPrefixStatus;
actorId?: string;
}
export type UpdateBusinessPrefixInput = Partial<Omit<CreateBusinessPrefixInput, 'actorId'>> & {
actorId?: string;
};
export interface BusinessPrefixesRepository {
list(query?: { keyword?: string; status?: BusinessPrefixStatus }): Promise<BusinessPrefixSummary[]>;
get(prefixId: string): Promise<BusinessPrefixSummary>;
create(input: CreateBusinessPrefixInput): Promise<BusinessPrefixSummary>;
update(prefixId: string, input: UpdateBusinessPrefixInput): Promise<BusinessPrefixSummary>;
setStatus(prefixId: string, status: BusinessPrefixStatus, actorId?: string): Promise<BusinessPrefixSummary>;
softDelete(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary>;
}
export const BUSINESS_PREFIXES_REPOSITORY = Symbol('BUSINESS_PREFIXES_REPOSITORY');
function businessPrefixId(): string {
return `bp_${crypto.randomUUID().replaceAll('-', '').slice(0, 29)}`;
}
function outboxId(): string {
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
}
@Injectable()
export class PrismaBusinessPrefixesRepository implements BusinessPrefixesRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(query: { keyword?: string; status?: BusinessPrefixStatus } = {}): Promise<BusinessPrefixSummary[]> {
const where: Prisma.BusinessPrefixWhereInput = {
deletedAt: null,
status: query.status,
OR: query.keyword
? [{ prefix: { contains: query.keyword } }, { name: { contains: query.keyword } }, { description: { contains: query.keyword } }]
: undefined
};
const items = await this.prisma.businessPrefix.findMany({
where,
orderBy: [{ priority: 'asc' }, { prefix: 'asc' }],
include: this.includeSummary()
});
return items.map((item) => this.toSummary(item));
}
async get(prefixId: string): Promise<BusinessPrefixSummary> {
return this.toSummary(await this.findActiveOrThrow(prefixId));
}
async create(input: CreateBusinessPrefixInput): Promise<BusinessPrefixSummary> {
const item = await this.prisma.$transaction(async (tx) => {
const created = await tx.businessPrefix.create({
data: {
id: businessPrefixId(),
prefix: input.prefix,
name: input.name,
description: input.description,
priority: input.priority,
status: input.status ?? 'ENABLED',
createdBy: input.actorId,
updatedBy: input.actorId
},
include: this.includeSummary()
});
await this.enqueueConfigOutbox(tx, created.id, 'business_prefix.created');
return created;
});
return this.toSummary(item);
}
async update(prefixId: string, input: UpdateBusinessPrefixInput): Promise<BusinessPrefixSummary> {
await this.findActiveOrThrow(prefixId);
const item = await this.prisma.$transaction(async (tx) => {
const updated = await tx.businessPrefix.update({
where: { id: prefixId },
data: {
prefix: input.prefix,
name: input.name,
description: input.description,
priority: input.priority,
status: input.status,
updatedBy: input.actorId,
version: { increment: 1 }
},
include: this.includeSummary()
});
await this.enqueueConfigOutbox(tx, updated.id, 'business_prefix.updated');
return updated;
});
return this.toSummary(item);
}
async setStatus(prefixId: string, status: BusinessPrefixStatus, actorId?: string): Promise<BusinessPrefixSummary> {
return this.update(prefixId, { status, actorId });
}
async softDelete(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
const existing = await this.findActiveOrThrow(prefixId);
const linkedGateways = await this.prisma.customerGatewayBusinessPrefix.count({
where: { businessPrefixId: prefixId }
});
if (linkedGateways > 0) {
throw new BadRequestException({
code: 'BUSINESS_PREFIX_IN_USE',
message: 'Business prefix in use by customer gateways cannot be deleted.'
});
}
const item = await this.prisma.$transaction(async (tx) => {
const updated = await tx.businessPrefix.update({
where: { id: existing.id },
data: {
status: 'DISABLED',
deletedAt: new Date(),
updatedBy: actorId,
version: { increment: 1 }
},
include: this.includeSummary()
});
await this.enqueueConfigOutbox(tx, updated.id, 'business_prefix.deleted');
return updated;
});
return this.toSummary(item);
}
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise<void> {
await tx.outboxEvent.create({
data: {
id: outboxId(),
aggregateType: 'business_prefix_config',
aggregateId,
eventType,
payload: Prisma.JsonNull
}
});
}
private includeSummary() {
return {
_count: {
select: {
customerGateways: true
}
}
} satisfies Prisma.BusinessPrefixInclude;
}
private async findActiveOrThrow(prefixId: string) {
const item = await this.prisma.businessPrefix.findUnique({
where: { id: prefixId },
include: this.includeSummary()
});
if (!item || item.deletedAt) {
throw new NotFoundException({ code: 'BUSINESS_PREFIX_NOT_FOUND', message: 'Business prefix not found.' });
}
return item;
}
private toSummary(item: {
id: string;
prefix: string;
name: string;
description: string | null;
priority: number;
status: BusinessPrefixStatus;
createdAt: Date;
updatedAt: Date;
_count: { customerGateways: number };
}): BusinessPrefixSummary {
return {
id: item.id,
prefix: item.prefix,
name: item.name,
description: item.description,
priority: item.priority,
status: item.status,
gatewayCount: item._count.customerGateways,
createdAt: item.createdAt,
updatedAt: item.updatedAt
};
}
}
@@ -0,0 +1,124 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import {
BUSINESS_PREFIXES_REPOSITORY,
type BusinessPrefixesRepository,
type BusinessPrefixStatus,
type BusinessPrefixSummary,
type CreateBusinessPrefixInput,
type UpdateBusinessPrefixInput
} from './business-prefixes.repository.js';
interface BusinessPrefixDto {
prefix?: unknown;
name?: unknown;
description?: unknown;
priority?: unknown;
status?: unknown;
}
interface ListQuery {
keyword?: unknown;
status?: unknown;
}
@Injectable()
export class BusinessPrefixesService {
constructor(@Inject(BUSINESS_PREFIXES_REPOSITORY) private readonly businessPrefixes: BusinessPrefixesRepository) {}
list(query: ListQuery = {}): Promise<BusinessPrefixSummary[]> {
return this.businessPrefixes.list({
keyword: this.optionalTrimmed(query.keyword, 80),
status: query.status === undefined || query.status === 'all' ? undefined : this.status(query.status)
});
}
get(prefixId: string): Promise<BusinessPrefixSummary> {
return this.businessPrefixes.get(prefixId);
}
create(body: BusinessPrefixDto, actorId?: string): Promise<BusinessPrefixSummary> {
const input: CreateBusinessPrefixInput = {
prefix: this.prefix(body.prefix),
name: this.requiredString(body.name, 'name', 120),
description: this.nullableString(body.description, 'description', 500),
priority: body.priority === undefined ? 100 : this.priority(body.priority),
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
actorId
};
return this.businessPrefixes.create(input);
}
update(prefixId: string, body: BusinessPrefixDto, actorId?: string): Promise<BusinessPrefixSummary> {
const input: UpdateBusinessPrefixInput = {
prefix: body.prefix === undefined ? undefined : this.prefix(body.prefix),
name: body.name === undefined ? undefined : this.requiredString(body.name, 'name', 120),
description: body.description === undefined ? undefined : this.nullableString(body.description, 'description', 500),
priority: body.priority === undefined ? undefined : this.priority(body.priority),
status: body.status === undefined ? undefined : this.status(body.status),
actorId
};
return this.businessPrefixes.update(prefixId, input);
}
enable(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
return this.businessPrefixes.setStatus(prefixId, 'ENABLED', actorId);
}
disable(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
return this.businessPrefixes.setStatus(prefixId, 'DISABLED', actorId);
}
remove(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
return this.businessPrefixes.softDelete(prefixId, actorId);
}
private prefix(value: unknown): string {
const trimmed = this.requiredString(value, 'prefix', 32);
if (!/^[A-Za-z0-9]{1,32}$/.test(trimmed)) {
throw new BadRequestException({ code: 'BUSINESS_PREFIX_INVALID', message: 'Business prefix must be 1-32 letters or digits.' });
}
return trimmed;
}
private requiredString(value: unknown, field: string, maxLength: number): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
}
const trimmed = value.trim();
if (trimmed.length > maxLength) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` });
}
return trimmed;
}
private nullableString(value: unknown, field: string, maxLength: number): string | null {
if (value === undefined || value === null || value === '') {
return null;
}
return this.requiredString(value, field, maxLength);
}
private optionalTrimmed(value: unknown, maxLength: number): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
return this.requiredString(value, 'keyword', maxLength);
}
private status(value: unknown): BusinessPrefixStatus {
if (value !== 'ENABLED' && value !== 'DISABLED') {
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
}
return value;
}
private priority(value: unknown): number {
const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : Number.NaN;
if (!Number.isInteger(numeric) || numeric < 1 || numeric > 9999) {
throw new BadRequestException({ code: 'PRIORITY_INVALID', message: 'Priority must be an integer between 1 and 9999.' });
}
return numeric;
}
}
+277 -10
View File
@@ -11,10 +11,19 @@ export interface CdrQuery {
vendorGatewayId?: string;
cityCode?: string;
carrier?: CdrCarrier;
startedFrom?: Date;
startedTo?: Date;
take: number;
skip: number;
}
export interface CdrPageMeta {
total: number;
take: number;
skip: number;
hasMore: boolean;
}
export interface CdrListItem {
id: string;
eventId: string;
@@ -22,6 +31,9 @@ export interface CdrListItem {
sourceIp: string | null;
caller: string;
callee: string;
rawCallee: string | null;
businessPrefixId: string | null;
businessPrefix: string | null;
calleeCityCode: string | null;
calleeCityName: string | null;
calleeProvinceName: string | null;
@@ -39,6 +51,8 @@ export interface CdrListItem {
vendorGatewayPort: number | null;
lineGroupId: string | null;
lineGroupName: string | null;
landingCaller: string | null;
landingCallee: string | null;
startedAt: Date;
answeredAt: Date | null;
endedAt: Date;
@@ -52,11 +66,73 @@ export interface CdrListItem {
vendorCost: string | null;
grossProfit: string | null;
billSec: number | null;
hasRecording: boolean;
recordingId: string | null;
recordingStatus: string | null;
}
export interface CdrEntityRef {
id: string;
name: string;
status?: string;
}
export interface CdrGatewayRef extends CdrEntityRef {
authMode?: string;
sourceIp?: string | null;
host?: string;
port?: number;
transport?: string;
}
export interface CdrPolicyRef extends CdrEntityRef {
priority: number;
callerMode: string;
callerValue: string | null;
calleeMode: string;
calleeValue: string | null;
}
export interface CdrRatedDetail {
id: string;
billSec: number;
customerFee: string;
vendorCost: string;
grossProfit: string;
customerRate: Prisma.JsonValue | null;
vendorRate: Prisma.JsonValue | null;
ratedAt: Date;
}
export interface CdrRecordingDetail {
id: string;
storageKey: string;
sha256: string | null;
bytes: string;
durationSec: number;
status: string;
movedAt: Date | null;
createdAt: Date;
}
export interface CdrDetail extends CdrListItem {
customer: CdrEntityRef | null;
customerGateway: CdrGatewayRef | null;
customerGatewayPolicy: CdrPolicyRef | null;
businessPrefixRef: CdrEntityRef & { prefix: string; priority: number } | null;
vendor: CdrEntityRef | null;
vendorGateway: CdrGatewayRef | null;
lineGroup: CdrEntityRef | null;
rated: CdrRatedDetail | null;
recording: CdrRecordingDetail | null;
payload: Prisma.JsonValue | null;
receivedAt: Date;
createdAt: Date;
}
export interface CdrsRepository {
list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }>;
get(id: string): Promise<CdrListItem>;
list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }>;
get(id: string): Promise<CdrDetail>;
}
export const CDRS_REPOSITORY = Symbol('CDRS_REPOSITORY');
@@ -69,6 +145,54 @@ type RawCdrRecord = Prisma.RawCdrGetPayload<{
vendorGateway: { select: { name: true; host: true; port: true } };
lineGroup: { select: { name: true } };
ratedCdr: { select: { customerFee: true; vendorCost: true; grossProfit: true; billSec: true } };
recording: { select: { id: true; status: true } };
};
}>;
type RawCdrDetailRecord = Prisma.RawCdrGetPayload<{
include: {
customer: { select: { id: true; name: true; status: true } };
customerGateway: { select: { id: true; name: true; status: true; authMode: true; sourceIp: true } };
customerGatewayPolicy: {
select: {
id: true;
name: true;
status: true;
priority: true;
callerMode: true;
callerValue: true;
calleeMode: true;
calleeValue: true;
};
};
businessPrefixRef: { select: { id: true; name: true; prefix: true; priority: true; status: true } };
vendor: { select: { id: true; name: true; status: true } };
vendorGateway: { select: { id: true; name: true; status: true; authMode: true; host: true; port: true; transport: true } };
lineGroup: { select: { id: true; name: true; status: true } };
ratedCdr: {
select: {
id: true;
billSec: true;
customerFee: true;
vendorCost: true;
grossProfit: true;
customerRate: true;
vendorRate: true;
ratedAt: true;
};
};
recording: {
select: {
id: true;
storageKey: true;
sha256: true;
bytes: true;
durationSec: true;
status: true;
movedAt: true;
createdAt: true;
};
};
};
}>;
@@ -76,7 +200,7 @@ type RawCdrRecord = Prisma.RawCdrGetPayload<{
export class PrismaCdrsRepository implements CdrsRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }> {
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }> {
const where = this.where(query);
const [items, total] = await this.prisma.$transaction([
this.prisma.rawCdr.findMany({
@@ -88,18 +212,26 @@ export class PrismaCdrsRepository implements CdrsRepository {
}),
this.prisma.rawCdr.count({ where })
]);
return { items: items.map((item) => this.toItem(item)), total };
return {
items: items.map((item) => this.toItem(item)),
meta: {
total,
take: query.take,
skip: query.skip,
hasMore: query.skip + items.length < total
}
};
}
async get(id: string): Promise<CdrListItem> {
async get(id: string): Promise<CdrDetail> {
const item = await this.prisma.rawCdr.findUnique({
where: { id },
include: this.includeCdr()
include: this.includeDetail()
});
if (!item) {
throw new NotFoundException({ code: 'CDR_NOT_FOUND', message: 'CDR not found.' });
}
return this.toItem(item);
return this.toDetail(item);
}
private where(query: CdrQuery): Prisma.RawCdrWhereInput {
@@ -109,7 +241,8 @@ export class PrismaCdrsRepository implements CdrsRepository {
customerGatewayId: query.customerGatewayId,
vendorGatewayId: query.vendorGatewayId,
calleeCityCode: query.cityCode,
calleeOperator: query.carrier
calleeOperator: query.carrier,
startedAt: query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined
};
}
@@ -120,7 +253,55 @@ export class PrismaCdrsRepository implements CdrsRepository {
vendor: { select: { name: true } },
vendorGateway: { select: { name: true, host: true, port: true } },
lineGroup: { select: { name: true } },
ratedCdr: { select: { customerFee: true, vendorCost: true, grossProfit: true, billSec: true } }
ratedCdr: { select: { customerFee: true, vendorCost: true, grossProfit: true, billSec: true } },
recording: { select: { id: true, status: true } }
} satisfies Prisma.RawCdrInclude;
}
private includeDetail() {
return {
customer: { select: { id: true, name: true, status: true } },
customerGateway: { select: { id: true, name: true, status: true, authMode: true, sourceIp: true } },
customerGatewayPolicy: {
select: {
id: true,
name: true,
status: true,
priority: true,
callerMode: true,
callerValue: true,
calleeMode: true,
calleeValue: true
}
},
businessPrefixRef: { select: { id: true, name: true, prefix: true, priority: true, status: true } },
vendor: { select: { id: true, name: true, status: true } },
vendorGateway: { select: { id: true, name: true, status: true, authMode: true, host: true, port: true, transport: true } },
lineGroup: { select: { id: true, name: true, status: true } },
ratedCdr: {
select: {
id: true,
billSec: true,
customerFee: true,
vendorCost: true,
grossProfit: true,
customerRate: true,
vendorRate: true,
ratedAt: true
}
},
recording: {
select: {
id: true,
storageKey: true,
sha256: true,
bytes: true,
durationSec: true,
status: true,
movedAt: true,
createdAt: true
}
}
} satisfies Prisma.RawCdrInclude;
}
@@ -132,6 +313,9 @@ export class PrismaCdrsRepository implements CdrsRepository {
sourceIp: item.sourceIp,
caller: item.caller,
callee: item.callee,
rawCallee: item.rawCallee,
businessPrefixId: item.businessPrefixId,
businessPrefix: item.businessPrefix,
calleeCityCode: item.calleeCityCode,
calleeCityName: item.calleeCityName,
calleeProvinceName: item.calleeProvinceName,
@@ -149,6 +333,8 @@ export class PrismaCdrsRepository implements CdrsRepository {
vendorGatewayPort: item.vendorGateway?.port ?? null,
lineGroupId: item.lineGroupId,
lineGroupName: item.lineGroup?.name ?? null,
landingCaller: item.landingCaller,
landingCallee: item.landingCallee,
startedAt: item.startedAt,
answeredAt: item.answeredAt,
endedAt: item.endedAt,
@@ -161,7 +347,88 @@ export class PrismaCdrsRepository implements CdrsRepository {
customerFee: item.ratedCdr?.customerFee.toFixed(6) ?? null,
vendorCost: item.ratedCdr?.vendorCost.toFixed(6) ?? null,
grossProfit: item.ratedCdr?.grossProfit.toFixed(6) ?? null,
billSec: item.ratedCdr?.billSec ?? null
billSec: item.ratedCdr?.billSec ?? null,
hasRecording: Boolean(item.recording),
recordingId: item.recording?.id ?? null,
recordingStatus: item.recording?.status ?? null
};
}
private toDetail(item: RawCdrDetailRecord): CdrDetail {
const base = this.toItem(item);
return {
...base,
customer: item.customer ? { id: item.customer.id, name: item.customer.name, status: item.customer.status } : null,
customerGateway: item.customerGateway
? {
id: item.customerGateway.id,
name: item.customerGateway.name,
status: item.customerGateway.status,
authMode: item.customerGateway.authMode,
sourceIp: item.customerGateway.sourceIp
}
: null,
customerGatewayPolicy: item.customerGatewayPolicy
? {
id: item.customerGatewayPolicy.id,
name: item.customerGatewayPolicy.name,
status: item.customerGatewayPolicy.status,
priority: item.customerGatewayPolicy.priority,
callerMode: item.customerGatewayPolicy.callerMode,
callerValue: item.customerGatewayPolicy.callerValue,
calleeMode: item.customerGatewayPolicy.calleeMode,
calleeValue: item.customerGatewayPolicy.calleeValue
}
: null,
businessPrefixRef: item.businessPrefixRef
? {
id: item.businessPrefixRef.id,
name: item.businessPrefixRef.name,
prefix: item.businessPrefixRef.prefix,
priority: item.businessPrefixRef.priority,
status: item.businessPrefixRef.status
}
: null,
vendor: item.vendor ? { id: item.vendor.id, name: item.vendor.name, status: item.vendor.status } : null,
vendorGateway: item.vendorGateway
? {
id: item.vendorGateway.id,
name: item.vendorGateway.name,
status: item.vendorGateway.status,
authMode: item.vendorGateway.authMode,
host: item.vendorGateway.host,
port: item.vendorGateway.port,
transport: item.vendorGateway.transport
}
: null,
lineGroup: item.lineGroup ? { id: item.lineGroup.id, name: item.lineGroup.name, status: item.lineGroup.status } : null,
rated: item.ratedCdr
? {
id: item.ratedCdr.id,
billSec: item.ratedCdr.billSec,
customerFee: item.ratedCdr.customerFee.toFixed(6),
vendorCost: item.ratedCdr.vendorCost.toFixed(6),
grossProfit: item.ratedCdr.grossProfit.toFixed(6),
customerRate: item.ratedCdr.customerRate,
vendorRate: item.ratedCdr.vendorRate,
ratedAt: item.ratedCdr.ratedAt
}
: null,
recording: item.recording
? {
id: item.recording.id,
storageKey: item.recording.storageKey,
sha256: item.recording.sha256,
bytes: item.recording.bytes.toString(),
durationSec: item.recording.durationSec,
status: item.recording.status,
movedAt: item.recording.movedAt,
createdAt: item.recording.createdAt
}
: null,
payload: item.payload,
receivedAt: item.receivedAt,
createdAt: item.createdAt
};
}
}
+45 -8
View File
@@ -1,17 +1,18 @@
import { describe, expect, it } from 'vitest';
import { BadRequestException } from '@nestjs/common';
import { CdrsService } from './cdrs.service.js';
import type { CdrListItem, CdrQuery, CdrsRepository } from './cdrs.repository.js';
import type { CdrDetail, CdrQuery, CdrsRepository } from './cdrs.repository.js';
class MemoryCdrsRepository implements CdrsRepository {
lastQuery: CdrQuery | null = null;
async list(query: CdrQuery) {
this.lastQuery = query;
return { items: [], total: 0 };
return { items: [], meta: { total: 0, take: query.take, skip: query.skip, hasMore: false } };
}
async get(id: string): Promise<CdrListItem> {
async get(id: string): Promise<CdrDetail> {
const now = new Date();
return {
id,
eventId: 'evt_1',
@@ -19,6 +20,9 @@ class MemoryCdrsRepository implements CdrsRepository {
sourceIp: null,
caller: '1001',
callee: '13800138000',
rawCallee: '67113800138000',
businessPrefixId: 'bp_seed',
businessPrefix: '671',
calleeCityCode: '340100',
calleeCityName: '合肥市',
calleeProvinceName: '安徽省',
@@ -36,9 +40,11 @@ class MemoryCdrsRepository implements CdrsRepository {
vendorGatewayPort: null,
lineGroupId: null,
lineGroupName: null,
startedAt: new Date(),
landingCaller: '02160010001',
landingCallee: '8613800138000',
startedAt: now,
answeredAt: null,
endedAt: new Date(),
endedAt: now,
durationSec: 0,
sipCode: 503,
hangupReason: 'NO_VENDOR_ROUTE_REGION_BLOCKED',
@@ -48,22 +54,47 @@ class MemoryCdrsRepository implements CdrsRepository {
customerFee: null,
vendorCost: null,
grossProfit: null,
billSec: null
billSec: null,
hasRecording: false,
recordingId: null,
recordingStatus: null,
customer: null,
customerGateway: null,
customerGatewayPolicy: null,
businessPrefixRef: null,
vendor: null,
vendorGateway: null,
lineGroup: null,
rated: null,
recording: null,
payload: null,
receivedAt: now,
createdAt: now
};
}
}
describe('CDR service', () => {
it('normalizes filters for city and carrier queries', async () => {
it('normalizes filters for city, carrier and time range queries', async () => {
const repository = new MemoryCdrsRepository();
const service = new CdrsService(repository);
await service.list({ caller: '1001', cityCode: '340100', carrier: 'MOBILE', take: '20', skip: '5' });
await service.list({
caller: '1001',
cityCode: '340100',
carrier: 'MOBILE',
startedFrom: '2026-06-24T00:00:00.000Z',
startedTo: '2026-06-25T00:00:00.000Z',
take: '20',
skip: '5'
});
expect(repository.lastQuery).toMatchObject({
caller: '1001',
cityCode: '340100',
carrier: 'MOBILE',
startedFrom: new Date('2026-06-24T00:00:00.000Z'),
startedTo: new Date('2026-06-25T00:00:00.000Z'),
take: 20,
skip: 5
});
@@ -74,4 +105,10 @@ describe('CDR service', () => {
expect(() => service.list({ carrier: 'BAD' })).toThrow(BadRequestException);
});
it('rejects inverted time ranges', () => {
const service = new CdrsService(new MemoryCdrsRepository());
expect(() => service.list({ from: '2026-06-25T00:00:00.000Z', to: '2026-06-24T00:00:00.000Z' })).toThrow(BadRequestException);
});
});
+19
View File
@@ -13,9 +13,14 @@ export class CdrsService {
vendorGatewayId: this.optionalString(rawQuery.vendorGatewayId, 32),
cityCode: this.optionalString(rawQuery.cityCode, 12),
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from),
startedTo: this.optionalDate(rawQuery.startedTo ?? rawQuery.endTime ?? rawQuery.to),
take: this.int(rawQuery.take, 100, 1, 500),
skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
};
if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) {
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: 'Time range is invalid.' });
}
return this.cdrs.list(query);
}
@@ -44,6 +49,20 @@ export class CdrsService {
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
}
private optionalDate(value: unknown): Date | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: 'Time range is invalid.' });
}
return date;
}
private int(value: unknown, defaultValue: number, min: number, max: number): number {
if (value === undefined) return defaultValue;
const parsed = Number(value);
@@ -44,6 +44,15 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
name: 'Seed IP Gateway',
authMode: 'IP',
sourceIp: '100.93.185.30',
sourceIps: ['100.93.185.30', '100.93.185.31'],
lineGroupId: 'llg_seed',
lineGroupName: 'Seed Line Group',
billingCycleSec: 6,
cycleRate: '0.010000',
callerMatchMode: 'PREFIXES',
callerPrefixes: ['021'],
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixes: [{ id: 'bp_seed', prefix: '671', name: 'Seed Prefix' }],
hasSipCredential: false,
policyCount: 1
})
@@ -64,9 +73,18 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
customerId: input.customerId,
name: input.name,
authMode: input.authMode,
sourceIp: input.sourceIp ?? null,
sourceIp: input.sourceIps?.[0] ?? null,
sourceIps: input.sourceIps ?? [],
sipUsername: input.sipUsername ?? null,
sipDomain: input.sipDomain ?? null,
lineGroupId: input.lineGroupId,
lineGroupName: 'Created Line Group',
billingCycleSec: input.billingCycleSec,
cycleRate: input.cycleRate,
callerMatchMode: input.callerMatchMode,
callerPrefixes: input.callerPrefixes,
calleeMatchMode: input.calleeMatchMode,
businessPrefixes: input.businessPrefixIds.map((id) => ({ id, prefix: '671', name: 'Seed Prefix' })),
hasSipCredential: Boolean(input.sipHa1)
});
this.gateways.set(gateway.id, gateway);
@@ -80,9 +98,19 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
customerId: input.customerId ?? current.customerId,
name: input.name ?? current.name,
authMode: input.authMode ?? current.authMode,
sourceIp: input.sourceIp === undefined ? current.sourceIp : input.sourceIp,
sourceIp: input.sourceIps === undefined ? current.sourceIp : input.sourceIps[0] ?? null,
sourceIps: input.sourceIps === undefined ? current.sourceIps : input.sourceIps,
sipUsername: input.sipUsername === undefined ? current.sipUsername : input.sipUsername,
sipDomain: input.sipDomain === undefined ? current.sipDomain : input.sipDomain,
lineGroupId: input.lineGroupId ?? current.lineGroupId,
lineGroupName: input.lineGroupId === undefined ? current.lineGroupName : 'Updated Line Group',
billingCycleSec: input.billingCycleSec ?? current.billingCycleSec,
cycleRate: input.cycleRate ?? current.cycleRate,
callerMatchMode: input.callerMatchMode ?? current.callerMatchMode,
callerPrefixes: input.callerPrefixes ?? current.callerPrefixes,
calleeMatchMode: input.calleeMatchMode ?? current.calleeMatchMode,
businessPrefixes:
input.businessPrefixIds === undefined ? current.businessPrefixes : input.businessPrefixIds.map((id) => ({ id, prefix: '671', name: 'Seed Prefix' })),
hasSipCredential: input.sipHa1 === undefined ? current.hasSipCredential : Boolean(input.sipHa1),
updatedAt: new Date('2026-06-21T03:00:00.000Z')
};
@@ -110,8 +138,17 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
name: string;
authMode?: 'IP' | 'SIP_DIGEST' | 'MIXED';
sourceIp?: string | null;
sourceIps?: string[];
sipUsername?: string | null;
sipDomain?: string | null;
lineGroupId?: string | null;
lineGroupName?: string | null;
billingCycleSec?: number;
cycleRate?: string;
callerMatchMode?: 'ANY' | 'PREFIXES';
callerPrefixes?: string[];
calleeMatchMode?: 'ANY' | 'BUSINESS_PREFIXES';
businessPrefixes?: Array<{ id: string; prefix: string; name: string }>;
hasSipCredential?: boolean;
status?: CustomerGatewayStatus;
policyCount?: number;
@@ -123,8 +160,17 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
name: input.name,
authMode: input.authMode ?? 'IP',
sourceIp: input.sourceIp ?? null,
sourceIps: input.sourceIps ?? (input.sourceIp ? [input.sourceIp] : []),
sipUsername: input.sipUsername ?? null,
sipDomain: input.sipDomain ?? null,
lineGroupId: input.lineGroupId ?? 'llg_seed',
lineGroupName: input.lineGroupName ?? 'Seed Line Group',
billingCycleSec: input.billingCycleSec ?? 60,
cycleRate: input.cycleRate ?? '0.000000',
callerMatchMode: input.callerMatchMode ?? 'ANY',
callerPrefixes: input.callerPrefixes ?? [],
calleeMatchMode: input.calleeMatchMode ?? 'ANY',
businessPrefixes: input.businessPrefixes ?? [],
hasSipCredential: input.hasSipCredential ?? false,
status: input.status ?? 'ENABLED',
policyCount: input.policyCount ?? 0,
@@ -208,6 +254,13 @@ describe('S13 customer gateways API', () => {
id: 'cgw_seed',
authMode: 'IP',
sourceIp: '100.93.185.30',
sourceIps: ['100.93.185.30', '100.93.185.31'],
lineGroupId: 'llg_seed',
billingCycleSec: 6,
cycleRate: '0.010000',
callerMatchMode: 'PREFIXES',
callerPrefixes: ['021'],
calleeMatchMode: 'BUSINESS_PREFIXES',
policyCount: 1
});
expect(JSON.stringify(response.body)).not.toContain('sipPassword');
@@ -232,7 +285,13 @@ describe('S13 customer gateways API', () => {
authMode: 'SIP_DIGEST',
sipUsername: 'alice-gw',
sipDomain: 'SIP.EXAMPLE.LOCAL',
sipPassword: 'change-me-very-strong'
sipPassword: 'change-me-very-strong',
lineGroupId: 'llg_seed',
billingCycleSec: 60,
cycleRate: '0.020000',
callerMatchMode: 'ANY',
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixIds: ['bp_seed']
})
.expect(201);
@@ -242,6 +301,10 @@ describe('S13 customer gateways API', () => {
sipUsername: 'alice-gw',
sipDomain: 'sip.example.local',
sourceIp: null,
sourceIps: [],
lineGroupId: 'llg_seed',
cycleRate: '0.020000',
businessPrefixes: [{ id: 'bp_seed', prefix: '671', name: 'Seed Prefix' }],
hasSipCredential: true
});
expect(response.body.sipPassword).toBeUndefined();
@@ -262,12 +325,13 @@ describe('S13 customer gateways API', () => {
await request(app.getHttpServer())
.patch('/api/v2/customer-gateways/cgw_created')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ authMode: 'IP', sourceIp: '100.93.185.32' })
.send({ authMode: 'IP', sourceIps: ['100.93.185.32', '100.93.185.33'], lineGroupId: 'llg_seed' })
.expect(200)
.expect((response) => {
expect(response.body).toMatchObject({
authMode: 'IP',
sourceIp: '100.93.185.32',
sourceIps: ['100.93.185.32', '100.93.185.33'],
sipUsername: null,
sipDomain: null,
hasSipCredential: false
@@ -5,6 +5,14 @@ import { PrismaService } from '../database/prisma.service.js';
export type CustomerGatewayStatus = 'ENABLED' | 'DISABLED';
export type CustomerGatewayAuthMode = 'IP' | 'SIP_DIGEST' | 'MIXED';
export type CustomerGatewayCallerMatchMode = 'ANY' | 'PREFIXES';
export type CustomerGatewayCalleeMatchMode = 'ANY' | 'BUSINESS_PREFIXES';
export interface CustomerGatewayBusinessPrefixSummary {
id: string;
prefix: string;
name: string;
}
export interface CustomerGatewaySummary {
id: string;
@@ -13,9 +21,18 @@ export interface CustomerGatewaySummary {
name: string;
authMode: CustomerGatewayAuthMode;
sourceIp: string | null;
sourceIps: string[];
sipUsername: string | null;
sipDomain: string | null;
hasSipCredential: boolean;
lineGroupId: string | null;
lineGroupName: string | null;
billingCycleSec: number;
cycleRate: string;
callerMatchMode: CustomerGatewayCallerMatchMode;
callerPrefixes: string[];
calleeMatchMode: CustomerGatewayCalleeMatchMode;
businessPrefixes: CustomerGatewayBusinessPrefixSummary[];
status: CustomerGatewayStatus;
policyCount: number;
createdAt: Date;
@@ -26,10 +43,17 @@ export interface CreateCustomerGatewayInput {
customerId: string;
name: string;
authMode: CustomerGatewayAuthMode;
sourceIp?: string | null;
sourceIps?: string[];
sipUsername?: string | null;
sipDomain?: string | null;
sipHa1?: string;
lineGroupId: string;
billingCycleSec: number;
cycleRate: string;
callerMatchMode: CustomerGatewayCallerMatchMode;
callerPrefixes: string[];
calleeMatchMode: CustomerGatewayCalleeMatchMode;
businessPrefixIds: string[];
actorId?: string;
}
@@ -37,10 +61,17 @@ export interface UpdateCustomerGatewayInput {
customerId?: string;
name?: string;
authMode?: CustomerGatewayAuthMode;
sourceIp?: string | null;
sourceIps?: string[];
sipUsername?: string | null;
sipDomain?: string | null;
sipHa1?: string | null;
lineGroupId?: string;
billingCycleSec?: number;
cycleRate?: string;
callerMatchMode?: CustomerGatewayCallerMatchMode;
callerPrefixes?: string[];
calleeMatchMode?: CustomerGatewayCalleeMatchMode;
businessPrefixIds?: string[];
actorId?: string;
}
@@ -63,6 +94,10 @@ function outboxId(): string {
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
}
function childId(prefix: string): string {
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32 - prefix.length - 1)}`;
}
@Injectable()
export class PrismaCustomerGatewaysRepository implements CustomerGatewaysRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
@@ -86,6 +121,8 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
async create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
await this.ensureCustomerExists(input.customerId);
await this.ensureLineGroupExists(input.lineGroupId);
await this.ensureBusinessPrefixesExist(input.businessPrefixIds);
try {
const gateway = await this.prisma.$transaction(async (tx) => {
@@ -95,17 +132,22 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
customerId: input.customerId,
name: input.name,
authMode: input.authMode,
sourceIp: input.sourceIp,
sourceIp: input.sourceIps?.[0] ?? null,
sipUsername: input.sipUsername,
sipDomain: input.sipDomain,
sipHa1: input.sipHa1,
lineGroupId: input.lineGroupId,
billingCycleSec: input.billingCycleSec,
cycleRate: new Prisma.Decimal(input.cycleRate),
callerMatchMode: input.callerMatchMode,
calleeMatchMode: input.calleeMatchMode,
createdBy: input.actorId,
updatedBy: input.actorId
},
include: this.includeSummary()
}
});
await this.replaceChildConfig(tx, created.id, input);
await this.enqueueConfigOutbox(tx, created.id, 'customer_gateway.changed');
return created;
return this.findActiveOrThrowInTx(tx, created.id);
});
return this.toSummary(gateway);
@@ -120,6 +162,12 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
if (input.customerId) {
await this.ensureCustomerExists(input.customerId);
}
if (input.lineGroupId) {
await this.ensureLineGroupExists(input.lineGroupId);
}
if (input.businessPrefixIds) {
await this.ensureBusinessPrefixesExist(input.businessPrefixIds);
}
try {
const gateway = await this.prisma.$transaction(async (tx) => {
@@ -129,17 +177,22 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
customerId: input.customerId,
name: input.name,
authMode: input.authMode,
sourceIp: input.sourceIp,
sourceIp: input.sourceIps === undefined ? undefined : input.sourceIps[0] ?? null,
sipUsername: input.sipUsername,
sipDomain: input.sipDomain,
sipHa1: input.sipHa1,
lineGroupId: input.lineGroupId,
billingCycleSec: input.billingCycleSec,
cycleRate: input.cycleRate === undefined ? undefined : new Prisma.Decimal(input.cycleRate),
callerMatchMode: input.callerMatchMode,
calleeMatchMode: input.calleeMatchMode,
updatedBy: input.actorId,
version: { increment: 1 }
},
include: this.includeSummary()
}
});
await this.replaceChildConfig(tx, updated.id, input);
await this.enqueueConfigOutbox(tx, updated.id, 'customer_gateway.changed');
return updated;
return this.findActiveOrThrowInTx(tx, updated.id);
});
return this.toSummary(gateway);
@@ -181,6 +234,14 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
updatedBy: actorId
}
});
await tx.customerGatewayIp.updateMany({
where: { gatewayId: gatewayIdValue, deletedAt: null },
data: {
status: 'DISABLED',
deletedAt: new Date(),
updatedBy: actorId
}
});
const deleted = await tx.customerGateway.update({
where: { id: gatewayIdValue },
data: {
@@ -224,6 +285,34 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
}
}
private async ensureLineGroupExists(lineGroupId: string): Promise<void> {
const lineGroup = await this.prisma.landingLineGroup.findUnique({
where: { id: lineGroupId },
select: { id: true, deletedAt: true }
});
if (!lineGroup || lineGroup.deletedAt) {
throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' });
}
}
private async ensureBusinessPrefixesExist(businessPrefixIds: string[]): Promise<void> {
if (businessPrefixIds.length === 0) {
return;
}
const count = await this.prisma.businessPrefix.count({
where: {
id: { in: businessPrefixIds },
deletedAt: null
}
});
if (count !== new Set(businessPrefixIds).size) {
throw new NotFoundException({ code: 'BUSINESS_PREFIX_NOT_FOUND', message: 'Business prefix not found.' });
}
}
private async findActiveOrThrow(gatewayIdValue: string) {
const gateway = await this.prisma.customerGateway.findUnique({
where: { id: gatewayIdValue },
@@ -237,6 +326,74 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
return gateway;
}
private async findActiveOrThrowInTx(tx: Prisma.TransactionClient, gatewayIdValue: string) {
const gateway = await tx.customerGateway.findUnique({
where: { id: gatewayIdValue },
include: this.includeSummary()
});
if (!gateway || gateway.deletedAt) {
throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_NOT_FOUND', message: 'Customer gateway not found.' });
}
return gateway;
}
private async replaceChildConfig(
tx: Prisma.TransactionClient,
gatewayIdValue: string,
input: {
sourceIps?: string[];
callerPrefixes?: string[];
businessPrefixIds?: string[];
actorId?: string;
}
): Promise<void> {
if (input.sourceIps !== undefined) {
await tx.customerGatewayIp.deleteMany({ where: { gatewayId: gatewayIdValue } });
if (input.sourceIps.length) {
await tx.customerGatewayIp.createMany({
data: input.sourceIps.map((sourceIp) => ({
id: childId('cgip'),
gatewayId: gatewayIdValue,
sourceIp,
createdBy: input.actorId,
updatedBy: input.actorId
}))
});
}
}
if (input.callerPrefixes !== undefined) {
await tx.customerGatewayCallerPrefix.deleteMany({ where: { gatewayId: gatewayIdValue } });
if (input.callerPrefixes.length) {
await tx.customerGatewayCallerPrefix.createMany({
data: input.callerPrefixes.map((prefix, index) => ({
id: childId('cgcp'),
gatewayId: gatewayIdValue,
prefix,
priority: (index + 1) * 10,
createdBy: input.actorId
}))
});
}
}
if (input.businessPrefixIds !== undefined) {
await tx.customerGatewayBusinessPrefix.deleteMany({ where: { gatewayId: gatewayIdValue } });
if (input.businessPrefixIds.length) {
await tx.customerGatewayBusinessPrefix.createMany({
data: input.businessPrefixIds.map((businessPrefixId) => ({
id: childId('cgbp'),
gatewayId: gatewayIdValue,
businessPrefixId,
createdBy: input.actorId
}))
});
}
}
}
private includeSummary() {
return {
customer: {
@@ -245,6 +402,31 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
name: true
}
},
lineGroup: {
select: {
id: true,
name: true
}
},
ips: {
where: { deletedAt: null },
orderBy: [{ createdAt: 'asc' }]
},
callerPrefixes: {
orderBy: [{ priority: 'asc' }, { prefix: 'asc' }]
},
businessPrefixes: {
include: {
businessPrefix: {
select: {
id: true,
prefix: true,
name: true
}
}
},
orderBy: [{ createdAt: 'asc' }]
},
_count: {
select: {
policies: {
@@ -261,9 +443,18 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
name: string;
authMode: CustomerGatewayAuthMode;
sourceIp: string | null;
ips: Array<{ sourceIp: string }>;
sipUsername: string | null;
sipDomain: string | null;
sipHa1: string | null;
lineGroupId: string | null;
lineGroup: { name: string } | null;
billingCycleSec: number;
cycleRate: Prisma.Decimal;
callerMatchMode: CustomerGatewayCallerMatchMode;
callerPrefixes: Array<{ prefix: string }>;
calleeMatchMode: CustomerGatewayCalleeMatchMode;
businessPrefixes: Array<{ businessPrefix: CustomerGatewayBusinessPrefixSummary }>;
status: CustomerGatewayStatus;
createdAt: Date;
updatedAt: Date;
@@ -277,9 +468,18 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
name: gateway.name,
authMode: gateway.authMode,
sourceIp: gateway.sourceIp,
sourceIps: gateway.ips.length ? gateway.ips.map((item) => item.sourceIp) : gateway.sourceIp ? [gateway.sourceIp] : [],
sipUsername: gateway.sipUsername,
sipDomain: gateway.sipDomain,
hasSipCredential: Boolean(gateway.sipHa1),
lineGroupId: gateway.lineGroupId,
lineGroupName: gateway.lineGroup?.name ?? null,
billingCycleSec: gateway.billingCycleSec,
cycleRate: gateway.cycleRate.toFixed(6),
callerMatchMode: gateway.callerMatchMode,
callerPrefixes: gateway.callerPrefixes.map((item) => item.prefix),
calleeMatchMode: gateway.calleeMatchMode,
businessPrefixes: gateway.businessPrefixes.map((item) => item.businessPrefix),
status: gateway.status,
policyCount: gateway._count.policies,
createdAt: gateway.createdAt,
@@ -5,6 +5,8 @@ import {
CUSTOMER_GATEWAYS_REPOSITORY,
type CreateCustomerGatewayInput,
type CustomerGatewayAuthMode,
type CustomerGatewayCalleeMatchMode,
type CustomerGatewayCallerMatchMode,
type CustomerGatewaySummary,
type CustomerGatewaysRepository,
type UpdateCustomerGatewayInput
@@ -15,9 +17,17 @@ interface CreateCustomerGatewayDto {
name?: unknown;
authMode?: unknown;
sourceIp?: unknown;
sourceIps?: unknown;
sipUsername?: unknown;
sipDomain?: unknown;
sipPassword?: unknown;
lineGroupId?: unknown;
billingCycleSec?: unknown;
cycleRate?: unknown;
callerMatchMode?: unknown;
callerPrefixes?: unknown;
calleeMatchMode?: unknown;
businessPrefixIds?: unknown;
}
interface UpdateCustomerGatewayDto {
@@ -25,9 +35,17 @@ interface UpdateCustomerGatewayDto {
name?: unknown;
authMode?: unknown;
sourceIp?: unknown;
sourceIps?: unknown;
sipUsername?: unknown;
sipDomain?: unknown;
sipPassword?: unknown;
lineGroupId?: unknown;
billingCycleSec?: unknown;
cycleRate?: unknown;
callerMatchMode?: unknown;
callerPrefixes?: unknown;
calleeMatchMode?: unknown;
businessPrefixIds?: unknown;
}
@Injectable()
@@ -47,14 +65,26 @@ export class CustomerGatewaysService {
const authMode = this.authMode(body.authMode);
const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain);
const sipPassword = this.requiredSipPassword(authMode, body.sipPassword);
const callerMatchMode = body.callerMatchMode === undefined ? 'ANY' : this.callerMatchMode(body.callerMatchMode);
const calleeMatchMode = body.calleeMatchMode === undefined ? 'ANY' : this.calleeMatchMode(body.calleeMatchMode);
const sourceIps = this.normalizeSourceIps(authMode, body.sourceIps ?? body.sourceIp);
const callerPrefixes = this.normalizeCallerPrefixes(callerMatchMode, body.callerPrefixes);
const businessPrefixIds = this.normalizeBusinessPrefixIds(calleeMatchMode, body.businessPrefixIds);
const input: CreateCustomerGatewayInput = {
customerId: this.limitedString(body.customerId, 'customerId', 32),
name: this.limitedString(body.name, 'name', 120),
authMode,
sourceIp: this.normalizeSourceIp(authMode, body.sourceIp),
sourceIps,
sipUsername: sipIdentity.sipUsername,
sipDomain: sipIdentity.sipDomain,
sipHa1: sipPassword ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, sipPassword) : undefined,
lineGroupId: this.limitedString(body.lineGroupId, 'lineGroupId', 32),
billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 86_400),
cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'),
callerMatchMode,
callerPrefixes,
calleeMatchMode,
businessPrefixIds,
actorId
};
@@ -67,9 +97,19 @@ export class CustomerGatewaysService {
const requestedSipUsername = body.sipUsername === undefined ? current.sipUsername : this.nullableString(body.sipUsername, 'sipUsername', 120);
const requestedSipDomain = body.sipDomain === undefined ? current.sipDomain : this.nullableString(body.sipDomain, 'sipDomain', 160);
const sipIdentity = this.normalizeSipIdentity(authMode, requestedSipUsername, requestedSipDomain);
const sourceIp = body.sourceIp === undefined ? current.sourceIp : this.nullableString(body.sourceIp, 'sourceIp', 45);
const normalizedSourceIp = this.normalizeSourceIp(authMode, sourceIp);
const sourceIps =
body.sourceIps === undefined && body.sourceIp === undefined
? current.sourceIps
: this.normalizeSourceIps(authMode, body.sourceIps ?? body.sourceIp);
const password = body.sipPassword === undefined ? undefined : this.requiredSipPassword(authMode, body.sipPassword);
const callerMatchMode = body.callerMatchMode === undefined ? current.callerMatchMode : this.callerMatchMode(body.callerMatchMode);
const calleeMatchMode = body.calleeMatchMode === undefined ? current.calleeMatchMode : this.calleeMatchMode(body.calleeMatchMode);
const callerPrefixes =
body.callerPrefixes === undefined ? current.callerPrefixes : this.normalizeCallerPrefixes(callerMatchMode, body.callerPrefixes);
const businessPrefixIds =
body.businessPrefixIds === undefined
? current.businessPrefixes.map((item) => item.id)
: this.normalizeBusinessPrefixIds(calleeMatchMode, body.businessPrefixIds);
if (this.requiresSip(authMode)) {
const identityChanged = sipIdentity.sipUsername !== current.sipUsername || sipIdentity.sipDomain !== current.sipDomain;
@@ -85,10 +125,18 @@ export class CustomerGatewaysService {
customerId: body.customerId === undefined ? undefined : this.limitedString(body.customerId, 'customerId', 32),
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
authMode,
sourceIp: normalizedSourceIp,
sourceIps,
sipUsername: sipIdentity.sipUsername,
sipDomain: sipIdentity.sipDomain,
sipHa1: password ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, password) : authMode === 'IP' ? null : undefined,
lineGroupId: body.lineGroupId === undefined ? undefined : this.limitedString(body.lineGroupId, 'lineGroupId', 32),
billingCycleSec:
body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 86_400),
cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'),
callerMatchMode,
callerPrefixes,
calleeMatchMode,
businessPrefixIds,
actorId
};
@@ -136,17 +184,26 @@ export class CustomerGatewaysService {
return value;
}
private normalizeSourceIp(authMode: CustomerGatewayAuthMode, value: unknown): string | null {
private normalizeSourceIps(authMode: CustomerGatewayAuthMode, value: unknown): string[] {
if (!this.requiresIp(authMode)) {
return null;
return [];
}
const sourceIp = this.limitedString(value, 'sourceIp', 45);
if (net.isIP(sourceIp) === 0) {
throw new BadRequestException({ code: 'SOURCE_IP_INVALID', message: 'sourceIp must be an IPv4 or IPv6 address.' });
const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,\s]+/) : [];
const sourceIps = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))];
if (sourceIps.length === 0) {
throw new BadRequestException({ code: 'SOURCE_IP_REQUIRED', message: 'At least one source IP is required.' });
}
if (sourceIps.length > 20) {
throw new BadRequestException({ code: 'SOURCE_IP_TOO_MANY', message: 'sourceIps supports up to 20 IP addresses.' });
}
for (const sourceIp of sourceIps) {
if (sourceIp.length > 45 || net.isIP(sourceIp) === 0) {
throw new BadRequestException({ code: 'SOURCE_IP_INVALID', message: 'sourceIps must be IPv4 or IPv6 addresses.' });
}
}
return sourceIp;
return sourceIps;
}
private normalizeSipIdentity(authMode: CustomerGatewayAuthMode, usernameValue: unknown, domainValue: unknown) {
@@ -197,4 +254,76 @@ export class CustomerGatewaysService {
private requiresSip(authMode: CustomerGatewayAuthMode): boolean {
return authMode === 'SIP_DIGEST' || authMode === 'MIXED';
}
private callerMatchMode(value: unknown): CustomerGatewayCallerMatchMode {
if (value !== 'ANY' && value !== 'PREFIXES') {
throw new BadRequestException({ code: 'CALLER_MATCH_MODE_INVALID', message: 'callerMatchMode is invalid.' });
}
return value;
}
private calleeMatchMode(value: unknown): CustomerGatewayCalleeMatchMode {
if (value !== 'ANY' && value !== 'BUSINESS_PREFIXES') {
throw new BadRequestException({ code: 'CALLEE_MATCH_MODE_INVALID', message: 'calleeMatchMode is invalid.' });
}
return value;
}
private normalizeCallerPrefixes(mode: CustomerGatewayCallerMatchMode, value: unknown): string[] {
if (mode === 'ANY') {
return [];
}
const prefixes = this.stringList(value, 'callerPrefixes', 20, 64);
if (prefixes.length === 0) {
throw new BadRequestException({ code: 'CALLER_PREFIX_REQUIRED', message: 'callerPrefixes is required.' });
}
for (const prefix of prefixes) {
if (!/^[A-Za-z0-9+*#._-]+$/.test(prefix)) {
throw new BadRequestException({ code: 'CALLER_PREFIX_INVALID', message: 'callerPrefixes contains invalid characters.' });
}
}
return prefixes;
}
private normalizeBusinessPrefixIds(mode: CustomerGatewayCalleeMatchMode, value: unknown): string[] {
if (mode === 'ANY') {
return [];
}
const ids = this.stringList(value, 'businessPrefixIds', 50, 32);
if (ids.length === 0) {
throw new BadRequestException({ code: 'BUSINESS_PREFIX_REQUIRED', message: 'businessPrefixIds is required.' });
}
return ids;
}
private stringList(value: unknown, field: string, maxItems: number, maxLength: number): string[] {
const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,\s]+/) : [];
const items = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))];
if (items.length > maxItems) {
throw new BadRequestException({ code: 'LIST_TOO_LARGE', message: `${field} supports up to ${maxItems} items.` });
}
for (const item of items) {
if (item.length > maxLength) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} item is too long.` });
}
}
return items;
}
private integer(value: unknown, field: string, min: number, max: number): number {
const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN;
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer between ${min} and ${max}.` });
}
return parsed;
}
private money(value: unknown, field: string): string {
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) {
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-negative decimal with up to 6 places.` });
}
const [integerPart, fractionPart = ''] = raw.split('.');
return `${integerPart}.${fractionPart.padEnd(6, '0')}`;
}
}
@@ -47,7 +47,9 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
cpsLimit: 20,
concurrencyLimit: 200,
cycleRate: '0.120000',
billingCycleSec: 60
billingCycleSec: 60,
landingCalleePrefix: '86',
callerRewritePool: [{ id: 'cr_seed', caller: '02160010001', weight: 10, status: 'ENABLED' }]
})
);
}
@@ -75,6 +77,8 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
concurrencyLimit: input.concurrencyLimit,
billingCycleSec: input.billingCycleSec,
cycleRate: input.cycleRate,
landingCalleePrefix: input.landingCalleePrefix ?? null,
callerRewritePool: input.callerRewritePool.map((item, index) => ({ id: `caller_${index}`, ...item })),
forbiddenPeriods: input.forbiddenPeriods.map((period, index) => ({ id: `period_${index}`, ...period })),
codecs: input.codecs.map((codec, index) => ({ id: `codec_${index}`, ...codec })),
prefixRules: input.prefixRules.map((rule, index) => ({ id: `rule_${index}`, ...rule }))
@@ -100,6 +104,9 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
concurrencyLimit: input.concurrencyLimit ?? current.concurrencyLimit,
billingCycleSec: input.billingCycleSec ?? current.billingCycleSec,
cycleRate: input.cycleRate ?? current.cycleRate,
landingCalleePrefix: input.landingCalleePrefix === undefined ? current.landingCalleePrefix : input.landingCalleePrefix,
callerRewritePool:
input.callerRewritePool === undefined ? current.callerRewritePool : input.callerRewritePool.map((item, index) => ({ id: `caller_updated_${index}`, ...item })),
status: input.status ?? current.status,
forbiddenPeriods: input.forbiddenPeriods === undefined ? current.forbiddenPeriods : input.forbiddenPeriods.map((period, index) => ({ id: `period_updated_${index}`, ...period })),
codecs: input.codecs === undefined ? current.codecs : input.codecs.map((codec, index) => ({ id: `codec_updated_${index}`, ...codec })),
@@ -141,6 +148,8 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
billingCycleSec,
cycleRate,
minuteRate: (Number(cycleRate) * 60 / billingCycleSec).toFixed(6),
landingCalleePrefix: input.landingCalleePrefix ?? null,
callerRewritePool: input.callerRewritePool ?? [],
status: input.status ?? 'ENABLED',
forbiddenPeriods: input.forbiddenPeriods ?? [],
codecs: input.codecs ?? [],
@@ -226,7 +235,9 @@ describe('S16 vendor gateways API', () => {
cpsLimit: 20,
concurrencyLimit: 200,
cycleRate: '0.120000',
minuteRate: '0.120000'
minuteRate: '0.120000',
landingCalleePrefix: '86',
callerRewritePool: [{ id: 'cr_seed', caller: '02160010001', weight: 10, status: 'ENABLED' }]
});
expect(JSON.stringify(response.body)).not.toContain('sipHa1');
});
@@ -256,6 +267,11 @@ describe('S16 vendor gateways API', () => {
concurrencyLimit: 300,
billingCycleSec: 6,
cycleRate: '0.012',
landingCalleePrefix: '86',
callerRewritePool: [
{ caller: '02160010001', weight: 80 },
{ caller: '02160010002', weight: 20 }
],
forbiddenPeriods: [{ weekdayMask: 62, startTime: '23:00:00', endTime: '23:59:59' }],
codecs: [
{ codec: 'PCMA', priority: 1 },
@@ -272,8 +288,10 @@ describe('S16 vendor gateways API', () => {
hasSipCredential: true,
billingCycleSec: 6,
cycleRate: '0.012000',
minuteRate: '0.120000'
minuteRate: '0.120000',
landingCalleePrefix: '86'
});
expect(response.body.callerRewritePool).toHaveLength(2);
expect(response.body.sipHa1).toBeUndefined();
});
@@ -283,8 +301,10 @@ describe('S16 vendor gateways API', () => {
.send({
authMode: 'IP',
host: '203.0.113.10',
landingCalleePrefix: 'ABC',
callerRewritePool: [{ caller: '02170020001', weight: 100 }],
codecs: [{ codec: 'G729', priority: 1 }],
prefixRules: [{ direction: 'CALLER', matchPrefix: '+86', replacePrefix: '0', priority: 1 }]
prefixRules: []
})
.expect(200)
.expect((response) => {
@@ -295,7 +315,9 @@ describe('S16 vendor gateways API', () => {
hasSipCredential: false
});
expect(response.body.codecs).toHaveLength(1);
expect(response.body.prefixRules[0]).toMatchObject({ direction: 'CALLER', matchPrefix: '+86' });
expect(response.body.prefixRules).toHaveLength(0);
expect(response.body).toMatchObject({ landingCalleePrefix: 'ABC' });
expect(response.body.callerRewritePool[0]).toMatchObject({ caller: '02170020001', weight: 100 });
});
expect(repository.outboxEvents).toBeGreaterThanOrEqual(2);
@@ -28,6 +28,13 @@ export interface PrefixRuleSummary {
priority: number;
}
export interface CallerRewriteSummary {
id: string;
caller: string;
weight: number;
status: VendorGatewayStatus;
}
export interface VendorGatewaySummary {
id: string;
vendorId: string;
@@ -44,6 +51,8 @@ export interface VendorGatewaySummary {
billingCycleSec: number;
cycleRate: string;
minuteRate: string;
landingCalleePrefix: string | null;
callerRewritePool: CallerRewriteSummary[];
status: VendorGatewayStatus;
forbiddenPeriods: ForbiddenPeriodSummary[];
codecs: CodecSummary[];
@@ -70,6 +79,12 @@ export interface PrefixRuleInput {
priority: number;
}
export interface CallerRewriteInput {
caller: string;
weight: number;
status: VendorGatewayStatus;
}
export interface CreateVendorGatewayInput {
vendorId: string;
name: string;
@@ -83,10 +98,12 @@ export interface CreateVendorGatewayInput {
concurrencyLimit: number;
billingCycleSec: number;
cycleRate: string;
landingCalleePrefix?: string | null;
status: VendorGatewayStatus;
forbiddenPeriods: ForbiddenPeriodInput[];
codecs: CodecInput[];
prefixRules: PrefixRuleInput[];
callerRewritePool: CallerRewriteInput[];
actorId?: string;
}
@@ -110,7 +127,7 @@ function gatewayId(): string {
}
function childId(prefix: string): string {
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32 - prefix.length - 1)}`;
}
function outboxId(): string {
@@ -156,12 +173,14 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
concurrencyLimit: input.concurrencyLimit,
billingCycleSec: input.billingCycleSec,
cycleRate: new Prisma.Decimal(input.cycleRate),
landingCalleePrefix: input.landingCalleePrefix,
status: input.status,
createdBy: input.actorId,
updatedBy: input.actorId,
forbiddenPeriods: { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) },
codecs: { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) },
prefixRules: { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) }
prefixRules: { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) },
callerRewritePool: { create: input.callerRewritePool.map((item) => this.callerRewriteCreate(item, input.actorId)) }
},
include: this.includeSummary()
});
@@ -192,6 +211,9 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
if (input.prefixRules) {
await tx.vendorGatewayPrefixRule.deleteMany({ where: { vendorGatewayId: gatewayIdValue } });
}
if (input.callerRewritePool) {
await tx.vendorGatewayCallerRewrite.deleteMany({ where: { vendorGatewayId: gatewayIdValue } });
}
const updated = await tx.vendorGateway.update({
where: { id: gatewayIdValue },
@@ -208,12 +230,14 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
concurrencyLimit: input.concurrencyLimit,
billingCycleSec: input.billingCycleSec,
cycleRate: input.cycleRate === undefined ? undefined : new Prisma.Decimal(input.cycleRate),
landingCalleePrefix: input.landingCalleePrefix,
status: input.status,
updatedBy: input.actorId,
version: { increment: 1 },
forbiddenPeriods: input.forbiddenPeriods ? { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) } : undefined,
codecs: input.codecs ? { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) } : undefined,
prefixRules: input.prefixRules ? { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) } : undefined
prefixRules: input.prefixRules ? { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) } : undefined,
callerRewritePool: input.callerRewritePool ? { create: input.callerRewritePool.map((item) => this.callerRewriteCreate(item, input.actorId)) } : undefined
},
include: this.includeSummary()
});
@@ -331,12 +355,24 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
};
}
private callerRewriteCreate(item: CallerRewriteInput, actorId?: string) {
return {
id: childId('vgcr'),
caller: item.caller,
weight: item.weight,
status: item.status,
createdBy: actorId,
updatedBy: actorId
};
}
private includeSummary() {
return {
vendor: { select: { name: true } },
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
codecs: { orderBy: [{ priority: 'asc' }] },
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] }
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] },
callerRewritePool: { where: { deletedAt: null }, orderBy: [{ weight: 'desc' }, { caller: 'asc' }] }
} satisfies Prisma.VendorGatewayInclude;
}
@@ -366,6 +402,7 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
concurrencyLimit: number;
billingCycleSec: number;
cycleRate: Prisma.Decimal;
landingCalleePrefix: string | null;
status: VendorGatewayStatus;
createdAt: Date;
updatedAt: Date;
@@ -373,6 +410,7 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
forbiddenPeriods: Array<{ id: string; weekdayMask: number; startTime: string; endTime: string }>;
codecs: Array<{ id: string; codec: string; priority: number }>;
prefixRules: Array<{ id: string; direction: PrefixDirection; matchPrefix: string; replacePrefix: string; priority: number }>;
callerRewritePool: Array<{ id: string; caller: string; weight: number; status: VendorGatewayStatus }>;
}): VendorGatewaySummary {
const minuteRate = gateway.cycleRate.mul(new Prisma.Decimal(60)).div(gateway.billingCycleSec);
return {
@@ -391,6 +429,8 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
billingCycleSec: gateway.billingCycleSec,
cycleRate: gateway.cycleRate.toFixed(6),
minuteRate: minuteRate.toFixed(6),
landingCalleePrefix: gateway.landingCalleePrefix,
callerRewritePool: gateway.callerRewritePool,
status: gateway.status,
forbiddenPeriods: gateway.forbiddenPeriods,
codecs: gateway.codecs,
@@ -4,6 +4,7 @@ import net from 'node:net';
import {
VENDOR_GATEWAYS_REPOSITORY,
type CodecInput,
type CallerRewriteInput,
type CreateVendorGatewayInput,
type ForbiddenPeriodInput,
type PrefixDirection,
@@ -28,6 +29,8 @@ interface VendorGatewayDto {
concurrencyLimit?: unknown;
billingCycleSec?: unknown;
cycleRate?: unknown;
landingCalleePrefix?: unknown;
callerRewritePool?: unknown;
status?: unknown;
forbiddenPeriods?: unknown;
codecs?: unknown;
@@ -65,10 +68,12 @@ export class VendorGatewaysService {
concurrencyLimit: body.concurrencyLimit === undefined ? 0 : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'),
landingCalleePrefix: this.optionalLandingPrefix(body.landingCalleePrefix),
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
forbiddenPeriods: this.forbiddenPeriods(body.forbiddenPeriods),
codecs: this.codecs(body.codecs),
prefixRules: this.prefixRules(body.prefixRules),
callerRewritePool: this.callerRewritePool(body.callerRewritePool),
actorId
};
return this.gateways.create(input);
@@ -104,10 +109,12 @@ export class VendorGatewaysService {
concurrencyLimit: body.concurrencyLimit === undefined ? undefined : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
billingCycleSec: body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'),
landingCalleePrefix: body.landingCalleePrefix === undefined ? undefined : this.optionalLandingPrefix(body.landingCalleePrefix),
status: body.status === undefined ? undefined : this.status(body.status),
forbiddenPeriods: body.forbiddenPeriods === undefined ? undefined : this.forbiddenPeriods(body.forbiddenPeriods),
codecs: body.codecs === undefined ? undefined : this.codecs(body.codecs),
prefixRules: body.prefixRules === undefined ? undefined : this.prefixRules(body.prefixRules),
callerRewritePool: body.callerRewritePool === undefined ? undefined : this.callerRewritePool(body.callerRewritePool),
actorId
};
return this.gateways.update(gatewayId, input);
@@ -316,6 +323,52 @@ export class VendorGatewaysService {
return text;
}
private optionalLandingPrefix(value: unknown): string | null {
if (value === undefined || value === null || value === '') {
return null;
}
const text = this.limitedString(value, 'landingCalleePrefix', 64);
if (!/^[A-Za-z0-9]+$/.test(text)) {
throw new BadRequestException({
code: 'LANDING_CALLEE_PREFIX_INVALID',
message: 'landingCalleePrefix must contain only letters or digits.'
});
}
return text;
}
private callerRewritePool(value: unknown): CallerRewriteInput[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new BadRequestException({ code: 'CALLER_REWRITE_POOL_INVALID', message: 'callerRewritePool must be an array.' });
}
if (value.length > 50) {
throw new BadRequestException({ code: 'CALLER_REWRITE_POOL_TOO_LARGE', message: 'callerRewritePool supports up to 50 items.' });
}
const pool = value
.map((item, index) => {
const record = item as Record<string, unknown>;
return {
caller: this.rewriteCaller(record.caller, `callerRewritePool[${index}].caller`),
weight: this.integer(record.weight, `callerRewritePool[${index}].weight`, 1, 100000),
status: record.status === undefined ? 'ENABLED' as const : this.status(record.status)
};
})
.filter((item) => item.caller);
this.ensureUnique(pool.map((item) => item.caller), 'CALLER_REWRITE_DUPLICATE', 'callerRewritePool callers must be unique.');
return pool;
}
private rewriteCaller(value: unknown, field: string): string {
const text = this.limitedString(value, field, 64);
if (!/^[A-Za-z0-9]+$/.test(text)) {
throw new BadRequestException({ code: 'CALLER_REWRITE_INVALID', message: `${field} must contain only letters or digits.` });
}
return text;
}
private ensureUnique(values: string[], code: string, message: string): void {
if (new Set(values).size !== values.length) {
throw new BadRequestException({ code, message });
+131 -3421
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -51,6 +51,31 @@ async function request(path, options = {}) {
return payload;
}
async function requestBlob(path, options = {}) {
const token = window.localStorage.getItem(ACCESS_TOKEN_KEY);
const headers = new Headers(options.headers || {});
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
if (!response.ok) {
const contentType = response.headers.get('content-type') || '';
const payload = contentType.includes('application/json') ? await response.json() : await response.text();
throw new ApiError(errorMessage(payload, response.status), {
status: response.status,
code: typeof payload === 'object' && payload ? payload.code : undefined,
});
}
return response.blob();
}
function errorMessage(payload, status) {
if (payload && typeof payload === 'object') {
return payload.message || payload.error || `API request failed with HTTP ${status}.`;
@@ -96,6 +121,17 @@ export const api = {
activeCalls: () => request('/active-calls'),
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 100, ...params })}`),
cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`),
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`),
recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`),
recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`),
saveRecordingReview: (id, body) => request(`/recordings/${encodeURIComponent(id)}/review`, { method: 'PUT', body: jsonBody(body) }),
qualityRules: () => request('/quality/rules'),
createQualityRule: (body) => request('/quality/rules', { method: 'POST', body: jsonBody(body) }),
updateQualityRule: (id, body) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
customers: () => request('/customers'),
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
@@ -115,10 +151,14 @@ export const api = {
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
}),
customerGateways: () => request('/customer-gateways'),
createCustomerGateway: (body) => request('/customer-gateways', { method: 'POST', 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' }),
disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
vendorGateways: () => request('/vendor-gateways'),
createVendorGateway: (body) => request('/vendor-gateways', { method: 'POST', 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' }),
disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
@@ -130,6 +170,12 @@ export const api = {
roles: () => request('/roles'),
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
auditLogs: () => request('/audit-logs?take=100'),
businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`),
createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', 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' }),
disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'DELETE' }),
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 100, ...params })}`),
importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }),
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 100, ...params })}`),
@@ -165,6 +211,12 @@ export function explainApiError(error) {
if (error instanceof ApiError && error.code === 'BUILT_IN_ROLE_PROTECTED') {
return '系统内置角色受保护,不能删除或修改关键权限。';
}
if (error instanceof ApiError && error.code === 'BUSINESS_PREFIX_IN_USE') {
return '该业务前缀仍被客户网关使用,不能删除。';
}
if (error instanceof ApiError && error.code === 'BUSINESS_PREFIX_INVALID') {
return '业务前缀只能包含 1-32 位英文或数字。';
}
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return '登录状态已失效,请重新登录。';
}
+209
View File
@@ -0,0 +1,209 @@
import { Alert, Badge, Button } from './ui.jsx';
const selectedBlue = '#2563EB';
export function toneForStatus(status) {
if (['启用', '在线', '正常', '生效', '已计费', '完成', '已完成', '开启', '可试听', '成功'].includes(status)) return 'success';
if (['观察', '抖动', '待质检', '处理中', '失败不计费'].includes(status)) return 'warning';
if (['停用', '禁用', '暂停', '告警', '关闭', '失败'].includes(status)) return 'danger';
return 'neutral';
}
export function Icon({ type }) {
const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
const paths = {
plus: <path d="M12 5v14M5 12h14" />,
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-3.4-3.4" /></>,
export: <><path d="M12 3v12" /><path d="m7 10 5 5 5-5" /><path d="M5 21h14" /></>,
reload: <><path d="M21 12a9 9 0 0 1-15 6.7" /><path d="M3 12a9 9 0 0 1 15-6.7" /><path d="M18 3v5h-5" /><path d="M6 21v-5h5" /></>,
arrow: <><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></>,
phoneOff: <><path d="M10.7 13.3a10.8 10.8 0 0 0 3.9 2.9" /><path d="M17 13.5l3 3v3a2 2 0 0 1-2.2 2A19.8 19.8 0 0 1 2.5 6.2 2 2 0 0 1 4.5 4h3l3 3-2 2a13 13 0 0 0 1.2 2.3" /><path d="M22 2 2 22" /></>,
collapse: <><path d="M15 18l-6-6 6-6" /><path d="M20 4v16" /></>,
expand: <><path d="M9 18l6-6-6-6" /><path d="M4 4v16" /></>,
logout: <><path d="M10 17l5-5-5-5" /><path d="M15 12H3" /><path d="M21 19V5a2 2 0 0 0-2-2h-5" /></>,
};
return <svg {...common}>{paths[type]}</svg>;
}
export function PageTitle({ title, desc, actions }) {
return (
<div className="page-title">
<div>
<h1>{title}</h1>
<p>{desc}</p>
</div>
<div className="page-actions">{actions}</div>
</div>
);
}
export function Toolbar({ children }) {
return <div className="toolbar">{children}</div>;
}
export function Panel({ title, aside, children, className = '' }) {
return (
<section className={`prototype-panel ${className}`}>
<div className="panel-head">
<h2>{title}</h2>
{aside}
</div>
{children}
</section>
);
}
export function ApiNotice({ loading, error, onRetry }) {
if (loading) {
return <Alert title="正在读取真实 API">正在从 LisgloSIPS API 拉取页面数据</Alert>;
}
if (error) {
return (
<Alert title="API 数据不可用" tone="warning">
{error}
{onRetry ? <Button size="sm" variant="outline" onClick={onRetry}>重试</Button> : null}
</Alert>
);
}
return null;
}
export function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) {
return (
<div className="empty-state">
<strong>{title}</strong>
<span>{children}</span>
</div>
);
}
export function Modal({ title, aside, children, onClose, size = 'lg' }) {
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<section className={`modal-dialog modal-${size}`} role="dialog" aria-modal="true" aria-label={title} onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h2>{title}</h2>
{aside}
</div>
<Button variant="ghost" size="sm" onClick={onClose}>关闭</Button>
</div>
<div className="modal-body">{children}</div>
</section>
</div>
);
}
export function ConfirmDialog({
title,
children,
confirmLabel = '确认',
confirmVariant = 'primary',
busy = false,
onCancel,
onConfirm,
}) {
return (
<Modal title={title} onClose={busy ? () => {} : onCancel} size="sm">
<div className="confirm-copy">
{typeof children === 'string' ? <p>{children}</p> : children}
</div>
<div className="modal-actions">
<Button type="button" variant="outline" disabled={busy} onClick={onCancel}>取消</Button>
<Button type="button" variant={confirmVariant} disabled={busy} onClick={onConfirm}>
{busy ? '处理中' : confirmLabel}
</Button>
</div>
</Modal>
);
}
export function Drawer({ title, aside, children, onClose }) {
return (
<div className="drawer-backdrop" role="presentation" onMouseDown={onClose}>
<aside className="drawer-panel" role="dialog" aria-modal="true" aria-label={title} onMouseDown={(event) => event.stopPropagation()}>
<div className="drawer-head">
<div>
<h2>{title}</h2>
{aside}
</div>
<Button variant="ghost" size="sm" onClick={onClose}>关闭</Button>
</div>
<div className="drawer-body">{children}</div>
</aside>
</div>
);
}
export function StatusBadge({ children }) {
return <Badge tone={toneForStatus(children)}>{children}</Badge>;
}
export function SimpleTable({ columns, rows, onRowClick, selectedKey }) {
return (
<div className="table-shell">
<table className="proto-table">
{columns.some((column) => column.width) ? (
<colgroup>
{columns.map((column) => <col key={column.key} style={column.width ? { width: column.width } : undefined} />)}
</colgroup>
) : null}
<thead>
<tr>
{columns.map((column) => <th key={column.key} className={column.className}>{column.label}</th>)}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length}>
<EmptyState />
</td>
</tr>
) : rows.map((row, index) => (
<tr
key={row.id || row.callId || row.name || index}
className={selectedKey && selectedKey === (row.id || row.callId) ? 'is-selected' : ''}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((column) => (
<td key={column.key} className={column.className}>
{column.status ? <StatusBadge>{row[column.key]}</StatusBadge> : column.render ? column.render(row) : row[column.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function MiniBarChart({ data, color = selectedBlue }) {
const max = Math.max(1, ...data);
return (
<div className="mini-chart" aria-label="趋势图">
{data.map((value, index) => (
<span key={index} style={{ height: `${Math.max(14, (value / max) * 100)}%`, background: color }} />
))}
</div>
);
}
export function LineChart({ data }) {
const points = data.map((value, index) => `${(index / Math.max(1, data.length - 1)) * 100},${100 - value}`).join(' ');
return (
<svg className="line-chart" viewBox="0 0 100 100" preserveAspectRatio="none" aria-label="接通率趋势">
<polyline points={points} fill="none" stroke={selectedBlue} strokeWidth="3" vectorEffect="non-scaling-stroke" />
</svg>
);
}
export function KeyValue({ label, value }) {
return (
<div className="kv">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
export const metrics = [
{ label: '今日通话数', value: '128,640', delta: '+12.4%', tone: 'up' },
{ label: '当前在线通话', value: '1,284', delta: '+86', tone: 'up' },
{ label: '今日接通率', value: '92.8%', delta: '-1.1%', tone: 'down' },
{ label: '客户消费', value: '¥86,420', delta: '+9.7%', tone: 'up' },
{ label: '供应商成本', value: '¥52,180', delta: '+7.8%', tone: 'up' },
{ label: '今日毛利', value: '¥34,240', delta: '39.6%', tone: 'neutral' },
{ label: '在线注册用户', value: '18,920', delta: '+312', tone: 'up' },
{ label: 'RTPEngine 节点', value: '1/1', delta: '正常', tone: 'up' },
{ label: 'OpenSIPS 节点', value: '1/1', delta: '单节点 V1', tone: 'neutral' },
{ label: '异常网关', value: '2', delta: '需关注', tone: 'warn' },
{ label: '质检待处理', value: '248', delta: 'P1', tone: 'warn' },
];
export const callTrend = [82, 96, 74, 118, 136, 162, 148, 176, 158, 190, 214, 188];
export const answerTrend = [90, 91, 88, 92, 94, 93, 91, 95, 94, 92, 93, 92];
export const customers = [
{ id: 'C1001', name: '上海示例通信', contact: '张经理', phone: '13800010001', email: 'ops-sh@example.net', domain: 'sh-voice.example.net', auth: 'IP 白名单', status: '启用', balance: '¥56,820.40', credit: '¥200,000', billing: '预付费', routeGroup: '华东优先线路', gateways: 3, createdAt: '2026-05-18' },
{ id: 'C1002', name: '杭州云呼叫中心', contact: '李主管', phone: '13900020002', email: 'contact-hz@example.net', domain: 'hz-call.example.net', auth: 'SIP 账号', status: '启用', balance: '¥18,445.80', credit: '¥80,000', billing: '后付费', routeGroup: '成本最低线路', gateways: 2, createdAt: '2026-05-22' },
{ id: 'C1003', name: '深圳跨境业务部', contact: '王经理', phone: '13700030003', email: 'global-sz@example.net', domain: 'sz-global.example.net', auth: '混合认证', status: '观察', balance: '¥6,921.00', credit: '¥50,000', billing: '预付费', routeGroup: '国际备线池', gateways: 4, createdAt: '2026-06-01' },
{ id: 'C1004', name: '北京金融外呼', contact: '赵经理', phone: '13600040004', email: 'bj-fin@example.net', domain: 'bj-fin.example.net', auth: 'IP 白名单', status: '停用', balance: '¥0.00', credit: '¥100,000', billing: '后付费', routeGroup: '合规外呼线路', gateways: 1, createdAt: '2026-06-08' },
];
export const sipAccounts = [
{ user: '10010001', domain: 'sh-voice.example.net', register: '在线', contact: '10.12.8.21:5060', expires: '274s' },
{ user: '10010002', domain: 'sh-voice.example.net', register: '在线', contact: '10.12.8.22:5060', expires: '280s' },
{ user: '10010003', domain: 'sh-voice.example.net', register: '离线', contact: '-', expires: '-' },
];
export const vendors = [
{ id: 'V2001', name: '供应商 A', balance: '¥42,860.00', credit: '¥300,000', gateways: 8, status: '启用', cycle: '月结', ratePlan: 'CN-Mobile-2026', contact: '陈经理', createdAt: '2026-04-02' },
{ id: 'V2002', name: '供应商 B', balance: '¥18,200.00', credit: '¥120,000', gateways: 5, status: '启用', cycle: '周结', ratePlan: 'CN-LowCost-2026', contact: '王经理', createdAt: '2026-04-16' },
{ id: 'V2003', name: '国际供应商 C', balance: '¥8,450.00', credit: '¥80,000', gateways: 3, status: '观察', cycle: '月结', ratePlan: 'Global-Std', contact: 'Lina', createdAt: '2026-05-03' },
];
export const gateways = [
{ id: 'GW-A-01', vendor: '供应商 A', name: '华东移动主用', authMode: 'IP', ipAddress: '203.0.113.18', sipAccount: '', sipPassword: '', concurrencyLimit: 800, billingCycle: 60, cycleRate: 0.031, requestRate: '120 CPS', blockedProvinces: '新疆、西藏', callTimeLimit: '08:00-22:00', codecs: 'PCMA, PCMU', calleePrefixTransform: '13/15/18 保持原样', callerPrefixTransform: '0216001* -> 0216001*', status: '启用' },
{ id: 'GW-A-02', vendor: '供应商 A', name: '华东联通备用', authMode: 'IP', ipAddress: '203.0.113.19', sipAccount: '', sipPassword: '', concurrencyLimit: 500, billingCycle: 30, cycleRate: 0.017, requestRate: '80 CPS', blockedProvinces: '无', callTimeLimit: '00:00-23:59', codecs: 'PCMA', calleePrefixTransform: '021 保持原样', callerPrefixTransform: '0217002* -> 0217002*', status: '启用' },
{ id: 'GW-B-01', vendor: '供应商 B', name: '成本最低路由', authMode: 'SIP注册', ipAddress: '', sipAccount: 'vendor-b-main', sipPassword: '******', concurrencyLimit: 360, billingCycle: 6, cycleRate: 0.0028, requestRate: '60 CPS', blockedProvinces: '北京', callTimeLimit: '09:00-21:00', codecs: 'PCMA, G729', calleePrefixTransform: '0571 -> 0571', callerPrefixTransform: '0571888* -> 0571888*', status: '禁用' },
];
export const vendorLineGroups = [
{ id: 'VLG-01', name: '华东主备线路组', gatewayIds: ['GW-A-01', 'GW-A-02'] },
{ id: 'VLG-02', name: '低成本线路组', gatewayIds: ['GW-B-01', 'GW-A-02'] },
{ id: 'VLG-03', name: '移动优先线路组', gatewayIds: ['GW-A-01'] },
];
export const customerGatewayPolicies = [
{ id: 'CGP-001', gateway: 'C-GW-SH-01', name: '上海移动外呼', callerMode: 'prefix', callerValue: '0216001', calleeMode: 'prefix', calleeValue: '13/15/18', routeGroup: '华东优先线路', priority: 10, status: '启用' },
{ id: 'CGP-002', gateway: 'C-GW-SH-01', name: '上海本地回访', callerMode: 'prefix', callerValue: '0217002', calleeMode: 'prefix', calleeValue: '021', routeGroup: '华东优先线路', priority: 20, status: '启用' },
{ id: 'CGP-003', gateway: 'C-GW-SH-01', name: '重点号码专线', callerMode: 'equals', callerValue: '02160010001', calleeMode: 'equals', calleeValue: '13800138000', routeGroup: '成本最低线路', priority: 30, status: '启用' },
{ id: 'CGP-004', gateway: 'C-GW-SH-02', name: '固话回访', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '021', routeGroup: '华东优先线路', priority: 10, status: '启用' },
{ id: 'CGP-005', gateway: 'C-GW-HZ-01', name: '浙江固话业务', callerMode: 'prefix', callerValue: '0571', calleeMode: 'prefix', calleeValue: '0571/0574', routeGroup: '成本最低线路', priority: 10, status: '启用' },
{ id: 'CGP-006', gateway: 'C-GW-HZ-01', name: '杭州移动外呼', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '13/15/18', routeGroup: '华东优先线路', priority: 20, status: '启用' },
{ id: 'CGP-007', gateway: 'C-GW-SZ-03', name: '国际直拨', callerMode: 'prefix', callerValue: '0755', calleeMode: 'prefix', calleeValue: '00', routeGroup: '国际备线池', priority: 10, status: '启用' },
{ id: 'CGP-008', gateway: 'C-GW-SZ-03', name: '美国方向', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '001', routeGroup: '国际备线池', priority: 20, status: '启用' },
{ id: 'CGP-009', gateway: 'C-GW-SZ-03', name: '英国方向', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '0044', routeGroup: '国际备线池', priority: 30, status: '启用' },
{ id: 'CGP-010', gateway: 'C-GW-SZ-03', name: '指定客户号码', callerMode: 'equals', callerValue: '075588801111', calleeMode: 'any', calleeValue: '', routeGroup: '成本最低线路', priority: 40, status: '停用' },
];
export const customerGateways = [
{ id: 'C-GW-SH-01', name: '上海主接入网关', authMode: 'IP', ipAddress: '10.10.1.11', sipAccount: '', sipPassword: '', routePolicyCount: 3, status: '启用' },
{ id: 'C-GW-SH-02', name: '上海固话回访入口', authMode: 'IP', ipAddress: '10.10.1.12', sipAccount: '', sipPassword: '', routePolicyCount: 1, status: '启用' },
{ id: 'C-GW-HZ-01', name: '杭州坐席接入', authMode: 'SIP注册', ipAddress: '', sipAccount: 'hz-seat-1001', sipPassword: '******', routePolicyCount: 2, status: '启用' },
{ id: 'C-GW-SZ-03', name: '深圳国际业务入口', authMode: 'SIP注册', ipAddress: '', sipAccount: 'sz-global-3003', sipPassword: '******', routePolicyCount: 4, status: '停用' },
];
export const initialRechargeRecords = [
{ id: 'RCG-20260618-003', type: 'customer', owner: '上海示例通信', amount: '¥10,000.00', beforeBalance: '¥46,820.40', afterBalance: '¥56,820.40', remark: '月初预存', operator: '财务', time: '2026-06-18 09:42:16', status: '成功' },
{ id: 'RCG-20260617-012', type: 'customer', owner: '杭州云呼叫中心', amount: '¥5,000.00', beforeBalance: '¥13,445.80', afterBalance: '¥18,445.80', remark: '补充测试额度', operator: '运营管理员', time: '2026-06-17 16:18:33', status: '成功' },
{ id: 'RCG-20260616-006', type: 'customer', owner: '深圳跨境业务部', amount: '¥2,000.00', beforeBalance: '¥4,921.00', afterBalance: '¥6,921.00', remark: '-', operator: '财务', time: '2026-06-16 11:07:25', status: '成功' },
{ id: 'RCG-20260618-V02', type: 'vendor', owner: '供应商 A', amount: '¥20,000.00', beforeBalance: '¥22,860.00', afterBalance: '¥42,860.00', remark: '线路预存款', operator: '财务', time: '2026-06-18 10:22:43', status: '成功' },
{ id: 'RCG-20260617-V01', type: 'vendor', owner: '国际供应商 C', amount: '¥5,000.00', beforeBalance: '¥3,450.00', afterBalance: '¥8,450.00', remark: '国际线路补款', operator: '运营管理员', time: '2026-06-17 15:39:08', status: '成功' },
];
export const vendorGatewayPolicies = [
{ vendor: '供应商 A', gateway: 'GW-A-01', caller: '0216001*', calleePrefix: '13/15/18', business: '国内移动成本线', vendorRate: 'A-Mobile-60/6', priority: 10, status: '启用' },
{ vendor: '供应商 A', gateway: 'GW-A-02', caller: '0217002*', calleePrefix: '021', business: '上海固话成本线', vendorRate: 'A-SH-Local-6/6', priority: 20, status: '启用' },
{ vendor: '供应商 B', gateway: 'GW-B-01', caller: '0571888*', calleePrefix: '0571', business: '杭州本地固话', vendorRate: 'B-HZ-Local-6/6', priority: 30, status: '观察' },
{ vendor: '国际供应商 C', gateway: 'GW-C-01', caller: '0755888*', calleePrefix: '001/0044', business: '国际长途', vendorRate: 'C-Global-60/60', priority: 10, status: '启用' },
];
export const routeGroups = [
{ id: 'RG-01', name: '华东优先线路', customers: '上海示例通信', gateways: 'GW-A-01, GW-A-02', strategy: '按优先级', retry: '503/408 自动重试', status: '启用' },
{ id: 'RG-02', name: '成本最低线路', customers: '杭州云呼叫中心', gateways: 'GW-B-01, GW-A-02', strategy: '按成本最低', retry: '失败换供应商', status: '启用' },
{ id: 'RG-03', name: '国际备线池', customers: '深圳跨境业务部', gateways: 'GW-C-01', strategy: '按号码前缀', retry: '不重试', status: '观察' },
];
export const routeRules = [
{ id: 'DR-001', customer: '上海示例通信', prefix: '13,15,18', routeGroup: '华东优先线路', priority: 10, window: '00:00-23:59', status: '生效', remark: '国内移动优先' },
{ id: 'DR-002', customer: '杭州云呼叫中心', prefix: '0571', routeGroup: '成本最低线路', priority: 20, window: '08:00-21:00', status: '生效', remark: '本地固话' },
{ id: 'DR-003', customer: '深圳跨境业务部', prefix: '00', routeGroup: '国际备线池', priority: 30, window: '00:00-23:59', status: '暂停', remark: '国际前缀' },
];
export const rates = [
{ type: '客户费率', owner: '上海示例通信', business: '国内移动外呼', gateway: 'C-GW-SH-01', caller: '-', region: '中国大陆-移动', prefix: '13/15/18', price: '¥0.052/分钟', cycle: '60/6', first: '60s', start: '2026-06-01', end: '-' },
{ type: '客户费率', owner: '杭州云呼叫中心', business: '浙江固话业务', gateway: 'C-GW-HZ-01', caller: '-', region: '中国大陆-固话', prefix: '0571', price: '¥0.038/分钟', cycle: '6/6', first: '6s', start: '2026-06-01', end: '-' },
{ type: '供应商费率', owner: '供应商 A', business: '国内移动成本线', gateway: 'GW-A-01', caller: '0216001*', region: '中国大陆-移动', prefix: '13/15/18', price: '¥0.031/分钟', cycle: '60/6', first: '60s', start: '2026-05-15', end: '-' },
{ type: '供应商费率', owner: '国际供应商 C', business: '国际长途', gateway: 'GW-C-01', caller: '0755888*', region: '美国', prefix: '001', price: '¥0.120/分钟', cycle: '60/60', first: '60s', start: '2026-05-21', end: '-' },
];
export const opsItems = [
{ name: 'OpenSIPS 主节点', target: '10.0.2.11:8080/mi', status: '正常', value: 'ps/get_statistics 可用' },
{ name: 'RTPEngine 节点 A', target: '10.0.2.21:2223', status: '正常', value: '会话 824' },
{ name: 'SIP 5060 监听', target: 'udp:0.0.0.0:5060', status: '正常', value: '监听中' },
{ name: 'Billing Worker 堆积', target: 'billing.queue', status: '观察', value: '1,284 条' },
{ name: '录音文件入库队列', target: 'recording.queue', status: '告警', value: '428 条' },
];
export const auditLogs = [
{ time: '2026-06-15 10:31:44', user: '运营管理员', action: '新增客户', object: '杭州云呼叫中心', ip: '10.1.8.32' },
{ time: '2026-06-15 10:24:09', user: '财务', action: '修改费率', object: 'CN-Mobile-60/6', ip: '10.1.8.45' },
{ time: '2026-06-15 09:58:51', user: '技术运维', action: 'dr_reload', object: 'OpenSIPS 主节点', ip: '10.1.8.88' },
{ time: '2026-06-15 09:42:18', user: '质检', action: '下载录音', object: '8b4d-0977', ip: '10.1.8.62' },
];
export const initialAdminUsers = [
{ id: 'U1001', username: 'admin', name: '系统管理员', phone: '13800000001', email: 'admin@lisglosips.local', roleId: 'R001', status: '启用', lastLogin: '2026-06-15 10:36:12', lastIp: '10.1.8.10' },
{ id: 'U1002', username: 'operator.li', name: '李运营', phone: '13800000012', email: 'operator@lisglosips.local', roleId: 'R002', status: '启用', lastLogin: '2026-06-15 10:31:44', lastIp: '10.1.8.32' },
{ id: 'U1003', username: 'finance.zhou', name: '周财务', phone: '13800000023', email: 'finance@lisglosips.local', roleId: 'R003', status: '启用', lastLogin: '2026-06-15 10:24:09', lastIp: '10.1.8.45' },
{ id: 'U1004', username: 'quality.wang', name: '王质检', phone: '13800000034', email: 'quality@lisglosips.local', roleId: 'R004', status: '启用', lastLogin: '2026-06-15 09:42:18', lastIp: '10.1.8.62' },
{ id: 'U1005', username: 'ops.chen', name: '陈运维', phone: '13800000045', email: 'ops@lisglosips.local', roleId: 'R005', status: '禁用', lastLogin: '2026-06-14 22:18:09', lastIp: '10.1.8.88' },
];
export const permissionGroups = [
{ name: '客户与供应商', permissions: [{ key: 'customers.view', label: '查看客户' }, { key: 'customers.manage', label: '管理客户' }, { key: 'vendors.view', label: '查看供应商' }, { key: 'vendors.manage', label: '管理供应商' }] },
{ name: '网关与路由', permissions: [{ key: 'customer_gateways.view', label: '查看客户网关' }, { key: 'customer_gateways.manage', label: '管理客户网关' }, { key: 'vendor_gateways.view', label: '查看落地网关' }, { key: 'vendor_gateways.manage', label: '管理落地网关' }, { key: 'line_groups.view', label: '查看落地线路组' }, { key: 'line_groups.manage', label: '管理落地线路组' }] },
{ name: '计费与财务', permissions: [{ key: 'recharges.view', label: '查看充值记录' }, { key: 'recharges.manage', label: '客户/供应商充值' }] },
{ name: '话单与质检', permissions: [{ key: 'active_calls.view', label: '查看当前通话' }, { key: 'active_calls.manage', label: '管理当前通话' }, { key: 'cdr.view', label: '查看话单' }, { key: 'quality.view', label: '查看质检' }, { key: 'quality.manage', label: '质检与评分' }, { key: 'recordings.play', label: '播放录音' }] },
{ name: '号码库与监控', permissions: [{ key: 'number_library.view', label: '查看号码库' }, { key: 'number_library.manage', label: '管理号码库' }, { key: 'dashboard.view', label: '查看概览' }] },
{ name: '系统管理', permissions: [{ key: 'users.view', label: '查看用户' }, { key: 'users.manage', label: '管理用户' }, { key: 'roles.view', label: '查看角色权限' }, { key: 'roles.manage', label: '管理角色权限' }, { key: 'audit.view', label: '查看操作日志' }] },
];
export const allPermissionKeys = permissionGroups.flatMap((group) => group.permissions.map((permission) => permission.key));
export const initialRoles = [
{ id: 'R001', name: '超级管理员', description: '拥有平台全部功能与数据权限', status: '启用', builtIn: true, permissions: allPermissionKeys },
{ id: 'R002', name: '运营管理员', description: '负责客户、网关、线路与日常运营', status: '启用', builtIn: true, permissions: ['dashboard.view', 'customers.view', 'customers.manage', 'vendors.view', 'customer_gateways.view', 'customer_gateways.manage', 'vendor_gateways.view', 'line_groups.view', 'line_groups.manage', 'cdr.view'] },
{ id: 'R003', name: '财务', description: '负责充值、账单、成本与导出', status: '启用', builtIn: true, permissions: ['dashboard.view', 'customers.view', 'vendors.view', 'recharges.view', 'recharges.manage', 'cdr.view'] },
{ id: 'R004', name: '质检', description: '负责录音抽检、质检评分与报告', status: '启用', builtIn: true, permissions: ['customers.view', 'cdr.view', 'quality.view', 'quality.manage', 'recordings.play'] },
{ id: 'R005', name: '技术运维', description: '负责 SIP 运维、监控和底层排障', status: '启用', builtIn: true, permissions: ['dashboard.view', 'active_calls.view', 'active_calls.manage', 'customer_gateways.view', 'vendor_gateways.view', 'line_groups.view', 'cdr.view', 'audit.view'] },
];
export const operationLogRows = [
{ id: 'LOG-20260615-001', time: '2026-06-15 10:31:44', user: '李运营', username: 'operator.li', module: '客户管理', action: '新增客户', object: '杭州云呼叫中心(C1002', result: '成功', ip: '10.1.8.32', summary: '创建客户并初始化默认账户配置', userAgent: 'Chrome 137 / Windows 11' },
{ id: 'LOG-20260615-002', time: '2026-06-15 10:24:09', user: '周财务', username: 'finance.zhou', module: '费率与计费', action: '修改费率', object: 'CN-Mobile-60/6', result: '成功', ip: '10.1.8.45', summary: '周期费率由 ¥0.036 调整为 ¥0.038', userAgent: 'Edge 137 / Windows 11' },
{ id: 'LOG-20260615-003', time: '2026-06-15 09:58:51', user: '陈运维', username: 'ops.chen', module: 'SIP 运维', action: '重载路由', object: 'OpenSIPS 主节点', result: '成功', ip: '10.1.8.88', summary: '执行 dr_reload,动态路由表重载完成', userAgent: 'Chrome 136 / Windows 10' },
{ id: 'LOG-20260615-004', time: '2026-06-15 09:42:18', user: '王质检', username: 'quality.wang', module: '质检中心', action: '播放录音', object: '8b4d-0977-202606150940', result: '成功', ip: '10.1.8.62', summary: '试听话单录音用于质检评分', userAgent: 'Chrome 137 / macOS 15' },
{ id: 'LOG-20260615-005', time: '2026-06-15 09:17:03', user: '李运营', username: 'operator.li', module: '线路与路由', action: '删除策略', object: '夜间国际备用线路', result: '失败', ip: '10.1.8.32', summary: '策略仍被客户网关引用,系统拒绝删除', userAgent: 'Chrome 137 / Windows 11' },
{ id: 'LOG-20260615-006', time: '2026-06-15 08:55:26', user: '系统管理员', username: 'admin', module: '用户管理', action: '禁用用户', object: 'ops.chenU1005', result: '成功', ip: '10.1.8.10', summary: '因账号交接临时禁用技术运维账号', userAgent: 'Chrome 137 / Windows 11' },
{ id: 'LOG-20260614-007', time: '2026-06-14 22:18:09', user: '陈运维', username: 'ops.chen', module: '认证', action: '用户登录', object: 'LisgloSIPS 运营端', result: '成功', ip: '10.1.8.88', summary: '密码认证成功', userAgent: 'Chrome 136 / Windows 10' },
];
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { formatDateTime, formatDurationText } from '../utils/formatters.js';
import { metrics } from '../fixtures/devFixtures.js';
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
const [busyId, setBusyId] = useState('');
const [hangupTarget, setHangupTarget] = useState(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const canManage = can('active_calls.manage');
const rows = activeCalls.map((call) => ({
...call,
callerText: call.caller || '-',
calleeText: call.callee || '-',
callerIpText: call.callerIp || '-',
landingIpText: call.landingIp || '-',
stateText: call.state || '未知',
startedText: formatDateTime(call.startedAt),
durationText: call.durationSec === null || call.durationSec === undefined ? '-' : formatDurationText(call.durationSec),
}));
const hangup = async (call) => {
setBusyId(call.id);
try {
await onHangupActiveCall(call.id);
} finally {
setBusyId('');
setHangupTarget(null);
}
};
useEffect(() => {
if (!autoRefresh) {
return undefined;
}
const timer = window.setInterval(() => {
if (!activeCallsLoading) {
void refreshActiveCalls();
}
}, 5000);
return () => window.clearInterval(timer);
}, [autoRefresh, activeCallsLoading, refreshActiveCalls]);
return (
<>
<PageTitle
title="当前通话"
desc="查看 OpenSIPS 当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
actions={(
<div className="table-actions">
<Button variant={autoRefresh ? 'secondary' : 'outline'} onClick={() => setAutoRefresh((value) => !value)}>
{autoRefresh ? '自动刷新中' : '开启自动刷新'}
</Button>
<Button icon={<Icon type="reload" />} onClick={refreshActiveCalls} disabled={activeCallsLoading}>刷新通话</Button>
</div>
)}
/>
<ApiNotice loading={activeCallsLoading} error={activeCallsError} onRetry={refreshActiveCalls} />
<section className="metric-grid active-call-metrics">
<div className="metric-card">
<span>当前通话数</span>
<strong>{rows.length}</strong>
<em className="metric-neutral">OpenSIPS MI</em>
</div>
<div className="metric-card">
<span>最长通话</span>
<strong>{rows.length ? rows[0].durationText : '00:00'}</strong>
<em className="metric-neutral">按持续时长排序</em>
</div>
<div className="metric-card">
<span>控制面</span>
<strong>{activeCallsError ? '异常' : '就绪'}</strong>
<em className={activeCallsError ? 'metric-warn' : 'metric-neutral'}>MI 受控访问</em>
</div>
</section>
<Panel title="实时呼叫列表" aside={<Badge tone={rows.length ? 'success' : 'neutral'}>{rows.length} </Badge>} className="wide-panel">
<SimpleTable rows={rows} columns={[
{ key: 'callId', label: 'Call-ID' },
{ key: 'callerText', label: '主叫' },
{ key: 'calleeText', label: '被叫' },
{ key: 'callerIpText', label: '呼叫方 IP' },
{ key: 'landingIpText', label: '落地 IP' },
{ key: 'stateText', label: '状态', status: true },
{ key: 'durationText', label: '持续时长' },
{ key: 'startedText', label: '开始时间' },
{
key: 'action',
label: '操作',
render: (row) => canManage ? (
<Button
size="sm"
variant="danger"
icon={<Icon type="phoneOff" />}
disabled={busyId === row.id}
onClick={(event) => {
event.stopPropagation();
setHangupTarget(row);
}}
>
{busyId === row.id ? '挂断中' : '强制挂断'}
</Button>
) : '-'
},
]} />
</Panel>
{hangupTarget ? (
<ConfirmDialog
title="强制挂断确认"
confirmLabel="强制挂断"
confirmVariant="danger"
busy={busyId === hangupTarget.id}
onCancel={() => setHangupTarget(null)}
onConfirm={() => void hangup(hangupTarget)}
>
<p>确认强制挂断当前通话</p>
<p className="muted-text">{hangupTarget.callId || hangupTarget.id}</p>
</ConfirmDialog>
) : null}
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Alert, Button, Progress } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, SimpleTable, KeyValue } from '../components/layout.jsx';
import { rates } from '../fixtures/devFixtures.js';
export function BillingPage() {
return (
<>
<PageTitle title="费率与计费" desc="客户费率、供应商费率、按业务映射匹配和 Billing Worker 计费链路。" actions={<Button icon={<Icon type="plus" />}>新增费率</Button>} />
<section className="content-grid">
<Panel title="客户/供应商费率" className="wide-panel">
<SimpleTable rows={rates} columns={[
{ key: 'type', label: '类型' },
{ key: 'owner', label: '客户/供应商' },
{ key: 'business', label: '业务名称' },
{ key: 'gateway', label: '网关' },
{ key: 'caller', label: '主叫号码/号段' },
{ key: 'region', label: '国家/地区' },
{ key: 'prefix', label: '号码前缀' },
{ key: 'price', label: '单价' },
{ key: 'cycle', label: '计费周期' },
{ key: 'first', label: '首周期' },
{ key: 'start', label: '生效时间' },
{ key: 'end', label: '失效时间' },
]} />
</Panel>
<Panel title="计费匹配维度">
<div className="flow">
{['客户', '客户网关', '来源 IP', '被叫前缀', '主叫号码', '业务类型', '供应商网关'].map((step) => <span key={step}>{step}</span>)}
</div>
<Alert title="计费说明">客户侧按客户网关 + 来源 IP + 被叫前缀匹配业务与费率供应商侧按供应商网关 + 被叫前缀或主叫号码匹配成本费率</Alert>
</Panel>
<Panel title="Billing Worker 流程">
<div className="flow">
{['读取未计费 CDR', '识别客户业务', '匹配客户费率', '匹配供应商业务', '匹配供应商费率', '计算费用/成本/毛利', '写入已计费话单', '更新余额/授信'].map((step) => <span key={step}>{step}</span>)}
</div>
<div className="formula">计费秒数 = ceil(实际通话秒数 / 计费周期) * 计费周期</div>
</Panel>
<Panel title="Worker 队列">
<KeyValue label="未计费 CDR" value="1,284 条" />
<Progress value={64} />
<KeyValue label="最近计费时间" value="2026-06-15 10:31:52" />
<KeyValue label="失败重试" value="12 条" />
</Panel>
<Panel title="预付费余额实时控制">
<div className="balance-control">
<KeyValue label="呼叫前检查" value="客户状态、余额、授信、外呼时段" />
<KeyValue label="通话中占用" value="按最大可通话时长冻结余额" />
<KeyValue label="计费后更新" value="扣减余额或更新授信占用" />
<Progress value={72} />
</div>
</Panel>
</section>
</>
);
}
+271
View File
@@ -0,0 +1,271 @@
import { useEffect, useState } from 'react';
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 { enStatus } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
export function BusinessPrefixesPage({ can = () => true }) {
const [rows, setRows] = useState([]);
const [filters, setFilters] = useState({ keyword: '', status: 'all' });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [editingPrefix, setEditingPrefix] = useState(undefined);
const [prefixForm, setPrefixForm] = useState(emptyBusinessPrefixForm);
const [statusTarget, setStatusTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [submitting, setSubmitting] = useState(false);
const canManage = can('customer_gateways.manage');
const loadPrefixes = async () => {
setLoading(true);
setError('');
try {
const payload = await api.businessPrefixes(filters);
setRows((Array.isArray(payload) ? payload : []).map(normalizeBusinessPrefix));
} catch (loadError) {
setError(explainApiError(loadError));
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadPrefixes();
}, []);
const openCreate = () => {
setEditingPrefix(null);
setPrefixForm(emptyBusinessPrefixForm);
setError('');
setMessage('');
};
const openEdit = (row) => {
setEditingPrefix(row);
setPrefixForm({
prefix: row.prefix,
name: row.name,
description: row.description === '-' ? '' : row.description,
priority: row.priority,
status: enStatus(row.status),
});
setError('');
setMessage('');
};
const closeForm = () => {
setEditingPrefix(undefined);
setPrefixForm(emptyBusinessPrefixForm);
setSubmitting(false);
};
const submitPrefix = async (event) => {
event.preventDefault();
setSubmitting(true);
setError('');
try {
const body = {
prefix: prefixForm.prefix.trim(),
name: prefixForm.name.trim(),
description: prefixForm.description.trim() || null,
priority: Number(prefixForm.priority),
status: prefixForm.status,
};
if (editingPrefix) {
await api.updateBusinessPrefix(editingPrefix.id, body);
setMessage(`业务前缀「${body.prefix}」已更新。`);
} else {
await api.createBusinessPrefix(body);
setMessage(`业务前缀「${body.prefix}」已创建。`);
}
closeForm();
await loadPrefixes();
} catch (submitError) {
setError(explainApiError(submitError));
} finally {
setSubmitting(false);
}
};
const toggleStatus = async (row) => {
setSubmitting(true);
setError('');
try {
if (row.status === '启用') {
await api.disableBusinessPrefix(row.id);
} else {
await api.enableBusinessPrefix(row.id);
}
setStatusTarget(null);
await loadPrefixes();
} catch (toggleError) {
setError(explainApiError(toggleError));
} finally {
setSubmitting(false);
}
};
const deletePrefix = async (row) => {
setSubmitting(true);
setError('');
try {
await api.deleteBusinessPrefix(row.id);
setDeleteTarget(null);
await loadPrefixes();
} catch (deleteError) {
setError(explainApiError(deleteError));
} finally {
setSubmitting(false);
}
};
const formOpen = editingPrefix !== undefined;
return (
<>
<PageTitle
title="业务前缀管理"
desc="维护客户呼入被叫号码前置的业务标识,后续客户网关按 IP、主叫规则和业务前缀识别归属。"
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreate}>新增业务前缀</Button> : null}
/>
<ApiNotice loading={loading} error={error} onRetry={loadPrefixes} />
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
<Panel
title="业务前缀"
className="wide-panel"
aside={<Button size="sm" variant="outline" icon={<Icon type="reload" />} onClick={loadPrefixes}>刷新</Button>}
>
<Toolbar>
<Field label="关键字">
<Input value={filters.keyword} onChange={(event) => setFilters({ ...filters, keyword: event.target.value })} placeholder="前缀或名称" />
</Field>
<Field label="状态">
<Select value={filters.status} onChange={(event) => setFilters({ ...filters, status: event.target.value })}>
<option value="all">全部</option>
<option value="ENABLED">启用</option>
<option value="DISABLED">停用</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button>
</Toolbar>
<SimpleTable rows={rows} columns={[
{ key: 'prefix', label: '业务前缀', width: '120px' },
{ key: 'name', label: '名称', width: '160px' },
{ key: 'priority', label: '优先级', width: '90px' },
{ key: 'gatewayCount', label: '使用客户网关数', width: '140px' },
{ key: 'status', label: '状态', width: '90px', status: true },
{ key: 'description', label: '备注' },
{ key: 'createdAt', label: '创建日期', width: '120px' },
{
key: 'actions',
label: '操作',
width: '220px',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEdit(row)}>编辑</Button> : null}
{canManage ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setStatusTarget(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteTarget(row)}>删除</Button> : '-'}
</div>
),
},
]} />
</Panel>
{formOpen ? (
<Modal title={editingPrefix ? '编辑业务前缀' : '新增业务前缀'} onClose={submitting ? () => {} : closeForm} size="sm">
<form className="modal-form" onSubmit={submitPrefix}>
<Field label={<span>业务前缀 <span className="required-star">*</span></span>}>
<Input value={prefixForm.prefix} onChange={(event) => setPrefixForm({ ...prefixForm, prefix: event.target.value })} placeholder="如 671" required />
</Field>
<Field label={<span>名称 <span className="required-star">*</span></span>}>
<Input value={prefixForm.name} onChange={(event) => setPrefixForm({ ...prefixForm, name: event.target.value })} placeholder="如 国内移动业务" required />
</Field>
<Field label="优先级">
<Input type="number" min="1" max="9999" value={prefixForm.priority} onChange={(event) => setPrefixForm({ ...prefixForm, priority: event.target.value })} />
</Field>
<Field label="状态">
<Select value={prefixForm.status} onChange={(event) => setPrefixForm({ ...prefixForm, status: event.target.value })}>
<option value="ENABLED">启用</option>
<option value="DISABLED">停用</option>
</Select>
</Field>
<Field label="备注">
<Textarea rows={3} value={prefixForm.description} onChange={(event) => setPrefixForm({ ...prefixForm, description: event.target.value })} />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" disabled={submitting} onClick={closeForm}>取消</Button>
<Button type="submit" disabled={submitting}>{submitting ? '保存中' : '保存'}</Button>
</div>
</form>
</Modal>
) : null}
{statusTarget ? (
<ConfirmDialog
title={`${statusTarget.status === '启用' ? '禁用' : '启用'}业务前缀确认`}
confirmLabel={`确认${statusTarget.status === '启用' ? '禁用' : '启用'}`}
busy={submitting}
onCancel={() => setStatusTarget(null)}
onConfirm={() => void toggleStatus(statusTarget)}
>
<p>确认{statusTarget.status === '启用' ? '禁用' : '启用'}业务前缀{statusTarget.prefix}</p>
</ConfirmDialog>
) : null}
{deleteTarget ? (
<ConfirmDialog
title="删除业务前缀确认"
confirmLabel="确认删除"
confirmVariant="danger"
busy={submitting}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void deletePrefix(deleteTarget)}
>
<p>确认删除业务前缀{deleteTarget.prefix}删除后不能再被客户网关选择</p>
</ConfirmDialog>
) : null}
</>
);
}
const numberLibraryTabs = [
{ value: 'cities', label: '地级市字典' },
{ value: 'phoneSegments', label: '手机号码库' },
{ value: 'areaCodes', label: '城市区号' },
{ value: 'carrierPrefixRules', label: '运营商号码段规则' },
];
const numberLibraryImportExamples = {
cities: [
{ code: '340100', provinceCode: '340000', provinceName: '安徽省', cityName: '合肥市', cityLevel: 'PREFECTURE' },
],
phoneSegments: [
{ segment7: '1380013', provinceName: '北京市', cityCode: '110100', cityName: '北京市', carrier: 'MOBILE' },
],
areaCodes: [
{ areaCode: '0551', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' },
],
carrierPrefixRules: [
{ prefix: '138', carrier: 'MOBILE', priority: 100 },
],
};
const emptyNumberLibraryRows = {
cities: [],
phoneSegments: [],
areaCodes: [],
carrierPrefixRules: [],
};
const emptyNumberLibraryTotals = {
cities: 0,
phoneSegments: 0,
areaCodes: 0,
carrierPrefixRules: 0,
};
function normalizeNumberLibraryList(payload, mapItem) {
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: items.map(mapItem),
total: payload?.total ?? items.length,
};
}
+392
View File
@@ -0,0 +1,392 @@
import { useEffect, useState } from 'react';
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 { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
const [detailCdr, setDetailCdr] = useState(null);
const [cdrRows, setCdrRows] = useState([]);
const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 50, skip: 0, hasMore: false });
const [cdrLoading, setCdrLoading] = useState(false);
const [cdrError, setCdrError] = useState('');
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
const [playbackLoading, setPlaybackLoading] = useState(false);
const [playbackError, setPlaybackError] = useState('');
const [playbackUrl, setPlaybackUrl] = useState('');
const [signalOpen, setSignalOpen] = useState(false);
const canPlayRecordings = can('recordings.play');
const [filters, setFilters] = useState({
caller: '',
callee: '',
customerGatewayId: 'all',
vendorGatewayId: 'all',
cityCode: '',
carrier: 'all',
startedFrom: '',
startedTo: '',
take: '50',
skip: 0,
});
const money = (value) => (value === null || value === undefined ? '¥0.000000' : formatCurrency(value, 6));
const ratingStatusLabel = (value) => ({
UNRATED: '未计费',
RATED: '已计费',
SKIPPED: '跳过计费',
FAILED: '计费失败',
}[value] || value || '-');
const normalizeCdr = (cdr) => {
const location = cdr.calleeCityName && cdr.calleeCityName !== 'UNKNOWN'
? `${cdr.calleeProvinceName || '-'} / ${cdr.calleeCityName}`
: '-';
const vendorGatewayHost = cdr.vendorGateway?.host || cdr.vendorGatewayHost;
const vendorGatewayPort = cdr.vendorGateway?.port || cdr.vendorGatewayPort;
const customerGatewayName = cdr.customerGateway?.name || cdr.customerGatewayName || cdr.customerGatewayId || '-';
const vendorGatewayName = cdr.vendorGateway?.name || cdr.vendorGatewayName || cdr.vendorGatewayId || '-';
const recording = cdr.recording || null;
const rated = cdr.rated || null;
return {
...cdr,
id: cdr.id,
customerName: cdr.customer?.name || cdr.customerName || cdr.customerId || '-',
vendorName: cdr.vendor?.name || cdr.vendorName || cdr.vendorId || '-',
lineGroupName: cdr.lineGroup?.name || cdr.lineGroupName || cdr.lineGroupId || '-',
customerGatewayName,
callIp: cdr.sourceIp || '-',
vendorGatewayName,
lineIp: vendorGatewayHost ? `${vendorGatewayHost}:${vendorGatewayPort || 5060}` : '-',
rawCalleeText: cdr.rawCallee || '-',
businessPrefixText: cdr.businessPrefix && cdr.businessPrefix !== 'none' ? cdr.businessPrefix : '-',
landingCallerText: cdr.landingCaller || '-',
landingCalleeText: cdr.landingCallee || '-',
callTime: formatDateTime(cdr.startedAt),
connectedTime: formatDateTime(cdr.answeredAt),
endTime: formatDateTime(cdr.endedAt),
durationText: formatDurationText(cdr.durationSec),
customerFee: money(cdr.customerFee),
costFee: money(cdr.vendorCost),
grossProfit: money(cdr.grossProfit),
customerRateText: rated?.customerRate ? JSON.stringify(rated.customerRate) : '-',
vendorRateText: rated?.vendorRate ? JSON.stringify(rated.vendorRate) : '-',
billSecText: cdr.billSec === null || cdr.billSec === undefined ? '-' : `${cdr.billSec}s`,
ratedAtText: formatDateTime(rated?.ratedAt),
location,
operatorText: carrierLabel(cdr.calleeOperator),
numberTypeText: cdr.calleeNumberType || '-',
hangupReason: cdr.hangupReason || '-',
sipCodeText: String(cdr.sipCode),
ratingStatusText: ratingStatusLabel(cdr.ratingStatus),
recordingId: cdr.recordingId || recording?.id || null,
recordingStatus: cdr.recordingStatus || recording?.status || null,
recordingText: cdr.hasRecording || recording ? zhStatus(cdr.recordingStatus || recording?.status || 'READY') : '无录音',
recordingKeyText: recording?.storageKey || cdr.recordingKey || '-',
recordingSizeText: recording?.bytes ? `${(Number(recording.bytes) / 1024 / 1024).toFixed(2)} MB` : '-',
traceText: JSON.stringify({
callId: cdr.callId,
eventId: cdr.eventId,
sourceIp: cdr.sourceIp,
customerGatewayId: cdr.customerGatewayId,
vendorGatewayId: cdr.vendorGatewayId,
configVersion: cdr.configVersion,
payload: cdr.payload || null,
}, null, 2),
};
};
const queryParams = (nextFilters = filters) => ({
caller: nextFilters.caller.trim(),
callee: nextFilters.callee.trim(),
customerGatewayId: nextFilters.customerGatewayId,
vendorGatewayId: nextFilters.vendorGatewayId,
cityCode: nextFilters.cityCode.trim(),
carrier: nextFilters.carrier,
startedFrom: nextFilters.startedFrom ? new Date(nextFilters.startedFrom).toISOString() : '',
startedTo: nextFilters.startedTo ? new Date(nextFilters.startedTo).toISOString() : '',
take: nextFilters.take,
skip: String(nextFilters.skip),
});
const loadCdrs = async (nextFilters = filters) => {
setCdrLoading(true);
setCdrError('');
try {
const response = await api.cdrs(queryParams(nextFilters));
setCdrRows((response.items || []).map(normalizeCdr));
setCdrMeta(response.meta || { total: response.total ?? 0, take: Number(nextFilters.take), skip: nextFilters.skip, hasMore: false });
} catch (error) {
setCdrError(explainApiError(error));
} finally {
setCdrLoading(false);
}
};
useEffect(() => {
void loadCdrs();
}, []);
useEffect(() => () => {
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
}
}, [playbackUrl]);
const updateFilter = (key, value) => setFilters((current) => ({ ...current, [key]: value }));
const searchCdrs = () => {
const nextFilters = { ...filters, skip: 0 };
setFilters(nextFilters);
void loadCdrs(nextFilters);
};
const resetFilters = () => {
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: '', startedTo: '', take: filters.take, skip: 0 };
setFilters(nextFilters);
void loadCdrs(nextFilters);
};
const changePage = (direction) => {
const take = Number(filters.take) || 50;
const nextSkip = Math.max(0, filters.skip + direction * take);
const nextFilters = { ...filters, skip: nextSkip };
setFilters(nextFilters);
void loadCdrs(nextFilters);
};
const openCdrDetail = async (row) => {
setDetailCdr(row);
setDetailError('');
setSignalOpen(false);
setPlaybackError('');
setPlaybackLoading(false);
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
setPlaybackUrl('');
}
setDetailLoading(true);
try {
const detail = await api.cdrDetail(row.id);
setDetailCdr(normalizeCdr(detail));
} catch (error) {
setDetailError(explainApiError(error));
} finally {
setDetailLoading(false);
}
};
const closeCdrDetail = () => {
setDetailCdr(null);
setDetailError('');
setSignalOpen(false);
setPlaybackError('');
setPlaybackLoading(false);
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
setPlaybackUrl('');
}
};
const loadRecordingPlayback = async () => {
if (!detailCdr?.recordingId || playbackLoading) return;
setPlaybackLoading(true);
setPlaybackError('');
try {
const blob = await api.recordingPlayback(detailCdr.recordingId);
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
}
setPlaybackUrl(URL.createObjectURL(blob));
} catch (error) {
setPlaybackError(explainApiError(error));
} finally {
setPlaybackLoading(false);
}
};
const pageStart = cdrMeta.total ? cdrMeta.skip + 1 : 0;
const pageEnd = Math.min(cdrMeta.total, cdrMeta.skip + cdrRows.length);
return (
<>
<PageTitle
title="话单中心"
desc="查询客户网关到落地网关的通话记录、费用、录音与信令详情。"
actions={<Button variant="secondary" icon={<Icon type="export" />} disabled>导出 CSV</Button>}
/>
<ApiNotice loading={cdrLoading} error={cdrError} onRetry={() => void loadCdrs()} />
<Toolbar>
<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="客户网关">
<Select value={filters.customerGatewayId} onChange={(event) => updateFilter('customerGatewayId', event.target.value)}>
<option value="all">全部客户网关</option>
{customerGatewayRows.map((gateway) => <option key={gateway.id} value={gateway.id}>{gateway.customer} / {gateway.name}</option>)}
</Select>
</Field>
<Field label="落地网关">
<Select value={filters.vendorGatewayId} onChange={(event) => updateFilter('vendorGatewayId', event.target.value)}>
<option value="all">全部落地网关</option>
{vendorGatewayRows.map((gateway) => <option key={gateway.id} value={gateway.id}>{gateway.vendor} / {gateway.name}</option>)}
</Select>
</Field>
<Field label="地级市代码"><Input value={filters.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
<Field label="运营商">
<Select value={filters.carrier} onChange={(event) => updateFilter('carrier', event.target.value)}>
<option value="all">全部</option>
<option value="MOBILE">移动</option>
<option value="UNICOM">联通</option>
<option value="TELECOM">电信</option>
<option value="BROADCAST">广电</option>
<option value="MVNO">虚拟运营商</option>
<option value="UNKNOWN">未知</option>
</Select>
</Field>
<Field label="开始时间"><Input type="datetime-local" value={filters.startedFrom} onChange={(event) => updateFilter('startedFrom', event.target.value)} /></Field>
<Field label="结束时间"><Input type="datetime-local" value={filters.startedTo} onChange={(event) => updateFilter('startedTo', event.target.value)} /></Field>
<Field label="每页">
<Select value={filters.take} onChange={(event) => {
const nextFilters = { ...filters, take: event.target.value, skip: 0 };
setFilters(nextFilters);
void loadCdrs(nextFilters);
}}>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} disabled={cdrLoading} onClick={searchCdrs}>查询</Button>
<Button variant="outline" disabled={cdrLoading} onClick={resetFilters}>重置</Button>
</Toolbar>
<Panel title="话单列表" className="wide-panel" aside={<Badge tone="info">{cdrMeta.total} </Badge>}>
<SimpleTable rows={cdrRows} columns={[
{ key: 'caller', label: '主叫号码' },
{ key: 'callee', label: '被叫号码' },
{ key: 'location', label: '地级市' },
{ key: 'operatorText', label: '运营商' },
{ key: 'customerGatewayName', 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: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
<Button size="sm" variant="outline" onClick={() => void openCdrDetail(row)}>详情</Button>
</div>
),
},
]} />
<div className="drawer-toolbar">
<div>
<strong>{pageStart}-{pageEnd}</strong>
<span> {cdrMeta.total} 按呼叫时间倒序</span>
</div>
<div className="table-actions">
<Button variant="outline" disabled={cdrLoading || cdrMeta.skip <= 0} onClick={() => changePage(-1)}>上一页</Button>
<Button variant="outline" disabled={cdrLoading || !cdrMeta.hasMore} onClick={() => changePage(1)}>下一页</Button>
</div>
</div>
</Panel>
{detailCdr ? (
<Drawer title="话单详情" aside={<Badge tone="info">{detailCdr.callId}</Badge>} onClose={closeCdrDetail}>
{detailLoading ? <Alert title="正在读取话单详情">正在加载计费录音和关联对象字段</Alert> : null}
{detailError ? <Alert title="话单详情读取失败" tone="warning">{detailError}</Alert> : null}
<div className="cdr-detail">
<section className="cdr-detail-hero">
<div>
<span>主叫</span>
<strong>{detailCdr.caller}</strong>
</div>
<Icon type="arrow" />
<div>
<span>被叫</span>
<strong>{detailCdr.callee}</strong>
</div>
</section>
<section className="cdr-detail-strip">
<KeyValue label="通话时长" value={detailCdr.durationText} />
<KeyValue label="挂断原因" value={detailCdr.hangupReason} />
<KeyValue label="地级市" value={detailCdr.location} />
<KeyValue label="运营商" value={detailCdr.operatorText} />
<KeyValue label="客户费用" value={detailCdr.customerFee} />
<KeyValue label="成本费用" value={detailCdr.costFee} />
<KeyValue label="毛利" value={detailCdr.grossProfit} />
<KeyValue label="计费秒数" value={detailCdr.billSecText} />
</section>
<section className="cdr-detail-section">
<h3>链路信息</h3>
<div className="cdr-detail-grid">
<KeyValue label="客户网关名称" value={detailCdr.customerGatewayName} />
<KeyValue label="呼叫 IP 地址" value={detailCdr.callIp} />
<KeyValue label="落地网关名称" value={detailCdr.vendorGatewayName} />
<KeyValue label="线路 IP 地址" value={detailCdr.lineIp} />
<KeyValue label="原始被叫" value={detailCdr.rawCalleeText} />
<KeyValue label="业务前缀" value={detailCdr.businessPrefixText} />
<KeyValue label="落地主叫" value={detailCdr.landingCallerText} />
<KeyValue label="落地被叫" value={detailCdr.landingCalleeText} />
<KeyValue label="号码类型" value={detailCdr.numberTypeText} />
<KeyValue label="SIP 状态码" value={detailCdr.sipCodeText} />
</div>
</section>
<section className="cdr-detail-section">
<h3>关联对象</h3>
<div className="cdr-detail-grid">
<KeyValue label="客户" value={detailCdr.customerName} />
<KeyValue label="供应商" value={detailCdr.vendorName} />
<KeyValue label="客户策略" value={detailCdr.customerGatewayPolicy?.name || detailCdr.customerGatewayPolicyId || '-'} />
<KeyValue label="落地线路组" value={detailCdr.lineGroupName} />
<KeyValue label="业务前缀名称" value={detailCdr.businessPrefixRef?.name || '-'} />
<KeyValue label="配置版本" value={detailCdr.configVersion || '-'} />
</div>
</section>
<section className="cdr-detail-section">
<h3>计费结果</h3>
<div className="cdr-detail-grid">
<KeyValue label="状态" value={detailCdr.ratingStatusText} />
<KeyValue label="计费时间" value={detailCdr.ratedAtText} />
<KeyValue label="客户费率" value={detailCdr.customerRateText} />
<KeyValue label="供应商费率" value={detailCdr.vendorRateText} />
</div>
</section>
<section className="cdr-detail-section">
<h3>时间轴</h3>
<div className="cdr-timeline">
<KeyValue label="呼叫时间" value={detailCdr.callTime} />
<KeyValue label="接通时间" value={detailCdr.connectedTime} />
<KeyValue label="结束时间" value={detailCdr.endTime} />
</div>
</section>
</div>
<div className="drawer-toolbar">
<div>
<strong>录音与信令</strong>
<span>{!canPlayRecordings ? '当前账号没有录音播放权限。' : detailCdr.recordingId ? detailCdr.recordingKeyText : '当前话单没有可播放录音。'}</span>
</div>
<div className="table-actions">
{canPlayRecordings ? <Button variant="secondary" disabled={!detailCdr.recordingId || playbackLoading} onClick={loadRecordingPlayback}>
{playbackLoading ? '读取录音' : playbackUrl ? '重新读取' : '录音播放'}
</Button> : null}
<Button variant={signalOpen ? 'secondary' : 'outline'} onClick={() => setSignalOpen((open) => !open)}>信令入口</Button>
</div>
</div>
{playbackError ? <Alert title="录音播放失败" tone="warning">{playbackError}</Alert> : null}
{playbackUrl ? (
<div className="cdr-detail-section">
<h3>录音播放</h3>
<audio className="recording-player" src={playbackUrl} controls preload="metadata" />
<div className="cdr-detail-grid">
<KeyValue label="录音状态" value={detailCdr.recordingText} />
<KeyValue label="文件大小" value={detailCdr.recordingSizeText} />
</div>
</div>
) : null}
{signalOpen ? (
<section className="cdr-detail-section">
<h3>信令索引</h3>
<pre className="trace-box">{detailCdr.traceText}</pre>
</section>
) : null}
</Drawer>
) : null}
</>
);
}
+529
View File
@@ -0,0 +1,529 @@
import { useEffect, useState } from 'react';
import { Alert, Badge, Button, Checkbox, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
import { authModeLabel } from '../utils/formatters.js';
import { customers, customerGatewayPolicies, customerGateways, routeGroups } from '../fixtures/devFixtures.js';
import { api, explainApiError } from '../api.js';
export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows: setApiGatewayRows, customerRows: apiCustomerRows, lineGroupRows: apiLineGroupRows, apiLoading, apiError, refreshApi, can = () => true, onCreateGateway, onUpdateGateway, onToggleGatewayStatus, onDeleteGateway }) {
const [localGatewayRows, setLocalGatewayRows] = useState(customerGateways);
const gatewayRows = Array.isArray(apiGatewayRows) ? apiGatewayRows : localGatewayRows;
const setGatewayRows = setApiGatewayRows || setLocalGatewayRows;
const customerOptions = apiCustomerRows?.length ? apiCustomerRows : customers;
const lineGroupOptions = apiLineGroupRows?.length ? apiLineGroupRows : [];
const [businessPrefixOptions, setBusinessPrefixOptions] = useState([]);
const [localError, setLocalError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [policyRows, setPolicyRows] = useState(customerGatewayPolicies);
const [strategyGateway, setStrategyGateway] = useState(null);
const [gatewayModalOpen, setGatewayModalOpen] = useState(false);
const [editingGateway, setEditingGateway] = useState(null);
const [policyModalOpen, setPolicyModalOpen] = useState(false);
const [editingPolicy, setEditingPolicy] = useState(null);
const [deletePolicyTarget, setDeletePolicyTarget] = useState(null);
const [gatewayConfirm, setGatewayConfirm] = useState(null);
const emptyGatewayForm = { customerId: '', name: '', authMode: 'IP', sourceIps: '', sipAccount: '', sipDomain: 'lisglosips.local', sipPassword: '', lineGroupId: '', billingCycleSec: 60, cycleRate: '0.000000', callerMatchMode: 'ANY', callerPrefixes: '', calleeMatchMode: 'ANY', businessPrefixIds: [] };
const emptyPolicyForm = { name: '', callerMode: 'any', callerValue: '', calleeMode: 'any', calleeValue: '', routeGroup: routeGroups[0].name };
const [gatewayForm, setGatewayForm] = useState(emptyGatewayForm);
const [policyForm, setPolicyForm] = useState(emptyPolicyForm);
const canManage = can('customer_gateways.manage');
const strategyGatewayData = gatewayRows.find((item) => item.id === strategyGateway);
const strategyPolicies = strategyGateway
? policyRows.filter((item) => item.gateway === strategyGateway).sort((first, second) => first.priority - second.priority)
: [];
useEffect(() => {
api.businessPrefixes({ status: 'ENABLED' })
.then((items) => setBusinessPrefixOptions(Array.isArray(items) ? items : []))
.catch((error) => setLocalError(explainApiError(error)));
}, []);
const formatMatch = (mode, value) => {
if (mode === 'equals') return `等于 ${value}`;
if (mode === 'prefix') return `前缀 ${value}`;
return '不限';
};
const openCreateGateway = () => {
setEditingGateway(null);
setGatewayForm({ ...emptyGatewayForm, customerId: customerOptions[0]?.id || '', lineGroupId: lineGroupOptions[0]?.id || '' });
setLocalError('');
setGatewayModalOpen(true);
};
const openEditGateway = (gateway) => {
setEditingGateway(gateway);
setGatewayForm({
customerId: gateway.customerId || '',
name: gateway.name,
authMode: gateway.authMode === '混合认证' ? 'MIXED' : gateway.authMode,
sourceIps: (gateway.sourceIps?.length ? gateway.sourceIps : gateway.ipAddress ? [gateway.ipAddress] : []).join('\n'),
sipAccount: gateway.sipAccount || '',
sipDomain: gateway.sipDomain || 'lisglosips.local',
sipPassword: '',
lineGroupId: gateway.lineGroupId || '',
billingCycleSec: gateway.billingCycleSec ?? 60,
cycleRate: String(gateway.cycleRate ?? '0.000000'),
callerMatchMode: gateway.callerMatchMode || 'ANY',
callerPrefixes: (gateway.callerPrefixes || []).join('\n'),
calleeMatchMode: gateway.calleeMatchMode || 'ANY',
businessPrefixIds: (gateway.businessPrefixes || []).map((item) => item.id),
});
setLocalError('');
setGatewayModalOpen(true);
};
const closeGatewayModal = () => {
setGatewayModalOpen(false);
setEditingGateway(null);
setGatewayForm(emptyGatewayForm);
setSubmitting(false);
};
const submitGateway = async (event) => {
event.preventDefault();
const name = gatewayForm.name.trim();
if (!gatewayForm.customerId || !name || !gatewayForm.lineGroupId) return;
const authMode = gatewayForm.authMode === 'SIP注册' ? 'SIP_DIGEST' : gatewayForm.authMode;
const body = {
customerId: gatewayForm.customerId,
name,
authMode,
sourceIps: gatewayForm.sourceIps.split(/[\n,\s]+/).map((item) => item.trim()).filter(Boolean),
sipUsername: gatewayForm.sipAccount.trim() || undefined,
sipDomain: gatewayForm.sipDomain.trim() || undefined,
lineGroupId: gatewayForm.lineGroupId,
billingCycleSec: Number(gatewayForm.billingCycleSec),
cycleRate: String(gatewayForm.cycleRate || '0'),
callerMatchMode: gatewayForm.callerMatchMode,
callerPrefixes: gatewayForm.callerPrefixes.split(/[\n,\s]+/).map((item) => item.trim()).filter(Boolean),
calleeMatchMode: gatewayForm.calleeMatchMode,
businessPrefixIds: gatewayForm.businessPrefixIds,
};
if (!editingGateway || gatewayForm.sipPassword.trim()) {
body.sipPassword = gatewayForm.sipPassword.trim();
}
setSubmitting(true);
setLocalError('');
try {
if (editingGateway && onUpdateGateway) {
await onUpdateGateway(editingGateway.id, body);
} else if (!editingGateway && onCreateGateway) {
await onCreateGateway(body);
} else {
const nextGateway = {
id: editingGateway?.id || `C-GW-${Date.now()}`,
customerId: body.customerId,
customer: customerOptions.find((item) => item.id === body.customerId)?.name || '-',
name,
authMode: authModeLabel(authMode),
sourceIps: body.sourceIps,
ipAddress: body.sourceIps[0] || '',
sipAccount: body.sipUsername || '',
sipDomain: body.sipDomain || '',
lineGroupId: body.lineGroupId,
lineGroupName: lineGroupOptions.find((item) => item.id === body.lineGroupId)?.name || '-',
billingCycleSec: body.billingCycleSec,
cycleRate: Number(body.cycleRate || 0),
callerMatchMode: body.callerMatchMode,
callerPrefixes: body.callerPrefixes,
calleeMatchMode: body.calleeMatchMode,
businessPrefixes: businessPrefixOptions.filter((item) => body.businessPrefixIds.includes(item.id)),
routePolicyCount: editingGateway?.routePolicyCount ?? 0,
status: editingGateway?.status ?? '启用',
};
setGatewayRows((rows) => (editingGateway ? rows.map((gateway) => (gateway.id === editingGateway.id ? nextGateway : gateway)) : [...rows, nextGateway]));
}
closeGatewayModal();
} catch (error) {
setLocalError(explainApiError(error));
} finally {
setSubmitting(false);
}
};
const toggleGatewayStatus = async (gateway) => {
setSubmitting(true);
setLocalError('');
if (onToggleGatewayStatus) {
try {
await onToggleGatewayStatus(gateway);
} catch (error) {
setLocalError(explainApiError(error));
} finally {
setSubmitting(false);
}
return;
}
setGatewayRows((rows) => rows.map((item) => (
item.id === gateway.id ? { ...item, status: item.status === '启用' ? '停用' : '启用' } : item
)));
setSubmitting(false);
};
const deleteGateway = async (gateway) => {
setSubmitting(true);
setLocalError('');
if (onDeleteGateway) {
try {
await onDeleteGateway(gateway.id);
} catch (error) {
setLocalError(explainApiError(error));
} finally {
setSubmitting(false);
}
return;
}
setGatewayRows((rows) => rows.filter((item) => item.id !== gateway.id));
setPolicyRows((rows) => rows.filter((policy) => policy.gateway !== gateway.id));
setSubmitting(false);
};
const openPolicyDrawer = (gatewayId) => {
setStrategyGateway(gatewayId);
setPolicyModalOpen(false);
setEditingPolicy(null);
setDeletePolicyTarget(null);
setPolicyForm(emptyPolicyForm);
};
const closePolicyDrawer = () => {
setStrategyGateway(null);
setPolicyModalOpen(false);
setEditingPolicy(null);
setDeletePolicyTarget(null);
setPolicyForm(emptyPolicyForm);
};
const openCreatePolicy = () => {
setEditingPolicy(null);
setPolicyForm(emptyPolicyForm);
setPolicyModalOpen(true);
};
const openEditPolicy = (policy) => {
setEditingPolicy(policy);
setPolicyForm({
name: policy.name,
callerMode: policy.callerMode,
callerValue: policy.callerValue,
calleeMode: policy.calleeMode,
calleeValue: policy.calleeValue,
routeGroup: policy.routeGroup,
});
setPolicyModalOpen(true);
};
const closePolicyModal = () => {
setPolicyModalOpen(false);
setEditingPolicy(null);
setPolicyForm(emptyPolicyForm);
};
const submitPolicy = (event) => {
event.preventDefault();
if (!strategyGatewayData || !policyForm.name.trim()) return;
const callerValue = policyForm.callerValue.trim();
const calleeValue = policyForm.calleeValue.trim();
const callerValid = policyForm.callerMode === 'any' || callerValue;
const calleeValid = policyForm.calleeMode === 'any' || calleeValue;
if (!callerValid || !calleeValid || (policyForm.callerMode === 'any' && policyForm.calleeMode === 'any')) return;
const nextPriority = strategyPolicies.length ? Math.max(...strategyPolicies.map((policy) => policy.priority)) + 10 : 10;
const nextPolicy = {
name: policyForm.name.trim(),
callerMode: policyForm.callerMode,
callerValue: policyForm.callerMode === 'any' ? '' : callerValue,
calleeMode: policyForm.calleeMode,
calleeValue: policyForm.calleeMode === 'any' ? '' : calleeValue,
routeGroup: policyForm.routeGroup,
};
if (editingPolicy) {
setPolicyRows((rows) => rows.map((policy) => (
policy.id === editingPolicy.id ? { ...policy, ...nextPolicy } : policy
)));
} else {
setPolicyRows((rows) => [
...rows,
{
...nextPolicy,
id: `CGP-${Date.now()}`,
gateway: strategyGatewayData.id,
priority: nextPriority,
status: '启用',
},
]);
setGatewayRows((rows) => rows.map((gateway) => (
gateway.id === strategyGatewayData.id ? { ...gateway, routePolicyCount: gateway.routePolicyCount + 1 } : gateway
)));
}
closePolicyModal();
};
const renumberPolicies = (policies) => policies.map((policy, index) => ({ ...policy, priority: (index + 1) * 10 }));
const movePolicy = (policyId, direction) => {
if (!strategyGatewayData) return;
const ordered = [...strategyPolicies];
const currentIndex = ordered.findIndex((policy) => policy.id === policyId);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= ordered.length) return;
[ordered[currentIndex], ordered[nextIndex]] = [ordered[nextIndex], ordered[currentIndex]];
const movedPolicies = renumberPolicies(ordered);
setPolicyRows((rows) => rows.map((policy) => (
policy.gateway === strategyGatewayData.id
? movedPolicies.find((item) => item.id === policy.id) || policy
: policy
)));
};
const togglePolicyStatus = (policyId) => {
setPolicyRows((rows) => rows.map((policy) => (
policy.id === policyId ? { ...policy, status: policy.status === '启用' ? '停用' : '启用' } : policy
)));
};
const deletePolicy = (policyId) => {
if (!strategyGatewayData) return;
setPolicyRows((rows) => renumberPolicies(rows.filter((policy) => policy.gateway === strategyGatewayData.id && policy.id !== policyId))
.concat(rows.filter((policy) => policy.gateway !== strategyGatewayData.id)));
setGatewayRows((rows) => rows.map((gateway) => (
gateway.id === strategyGatewayData.id ? { ...gateway, routePolicyCount: Math.max(0, gateway.routePolicyCount - 1) } : gateway
)));
setDeletePolicyTarget(null);
};
return (
<>
<PageTitle
title="客户网关管理"
desc="独立管理客户接入网关、多 IP、单落地线路组、客户侧费率和主被叫匹配规则。"
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreateGateway}>新增客户网关</Button> : null}
/>
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
{localError ? <Alert title="客户网关操作失败" tone="danger">{localError}</Alert> : null}
<section className="content-grid">
<Panel title="客户网关列表" className="wide-panel">
<SimpleTable rows={gatewayRows} columns={[
{ key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
{ key: 'customer', label: '客户', width: '148px', className: 'table-cell-compact' },
{ key: 'authMode', label: '认证方式' },
{ key: 'authTarget', label: 'IP地址 / 账号名称', render: (row) => (row.authMode === 'IP' || row.authMode === '混合认证' ? (row.sourceIps || []).join(', ') || row.ipAddress : row.sipAccount) },
{ key: 'lineGroupName', label: '落地线路组' },
{ key: 'rate', label: '客户费率', render: (row) => `${row.billingCycleSec || 60}s / ¥${Number(row.cycleRate || 0).toFixed(6)}` },
{ key: 'callerRule', label: '主叫规则', render: (row) => row.callerMatchMode === 'PREFIXES' ? (row.callerPrefixes || []).join(', ') : '任意号码' },
{ key: 'calleeRule', label: '被叫规则', render: (row) => row.calleeMatchMode === 'BUSINESS_PREFIXES' ? (row.businessPrefixes || []).map((item) => item.prefix).join(', ') : '任意号码' },
{ key: 'status', label: '状态', status: true },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditGateway(row)}>编辑</Button> : null}
{canManage ? (
<Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setGatewayConfirm({ type: 'toggle', row })}>
{row.status === '启用' ? '禁用' : '启用'}
</Button>
) : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setGatewayConfirm({ type: 'delete', row })}>删除</Button> : null}
{!canManage ? '-' : null}
</div>
),
},
]} />
</Panel>
</section>
{gatewayModalOpen ? (
<Modal title={editingGateway ? '编辑客户网关' : '新增客户网关'} onClose={submitting ? () => {} : closeGatewayModal} size="lg">
<form className="modal-form" onSubmit={submitGateway}>
<Field label={<span>所属客户 <span className="required-star">*</span></span>}>
<Select value={gatewayForm.customerId} onChange={(event) => setGatewayForm({ ...gatewayForm, customerId: event.target.value })} required>
{customerOptions.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}
</Select>
</Field>
<Field label={<span>名称 <span className="required-star">*</span></span>}>
<Input value={gatewayForm.name} onChange={(event) => setGatewayForm({ ...gatewayForm, name: event.target.value })} placeholder="请输入客户网关名称" required />
</Field>
<Field label={<span>认证方式 <span className="required-star">*</span></span>}>
<Select value={gatewayForm.authMode} onChange={(event) => setGatewayForm({ ...gatewayForm, authMode: event.target.value, sourceIps: '', sipAccount: '', sipPassword: '' })} required>
<option>IP</option>
<option>SIP注册</option>
<option value="MIXED">混合认证</option>
</Select>
</Field>
{gatewayForm.authMode === 'IP' || gatewayForm.authMode === 'MIXED' ? (
<Field label={<span>客户网关 IP <span className="required-star">*</span></span>}>
<Textarea rows={3} value={gatewayForm.sourceIps} onChange={(event) => setGatewayForm({ ...gatewayForm, sourceIps: event.target.value })} placeholder="每行一个 IP,例如 10.10.1.11" required />
</Field>
) : null}
{gatewayForm.authMode === 'SIP注册' || gatewayForm.authMode === 'MIXED' ? (
<>
<Field label={<span>SIP账号 <span className="required-star">*</span></span>}>
<Input value={gatewayForm.sipAccount} onChange={(event) => setGatewayForm({ ...gatewayForm, sipAccount: event.target.value })} placeholder="请输入 SIP 账号" required />
</Field>
<Field label={<span>SIP域 <span className="required-star">*</span></span>}>
<Input value={gatewayForm.sipDomain} onChange={(event) => setGatewayForm({ ...gatewayForm, sipDomain: event.target.value })} placeholder="例如 lisglosips.local" required />
</Field>
<Field label={editingGateway ? 'SIP密码(留空不修改)' : <span>SIP密码 <span className="required-star">*</span></span>}>
<Input type="password" value={gatewayForm.sipPassword} onChange={(event) => setGatewayForm({ ...gatewayForm, sipPassword: event.target.value })} placeholder="至少 12 位" required={!editingGateway} />
</Field>
</>
) : null}
<Field label={<span>落地线路组 <span className="required-star">*</span></span>}>
<Select value={gatewayForm.lineGroupId} onChange={(event) => setGatewayForm({ ...gatewayForm, lineGroupId: event.target.value })} required>
<option value="">请选择落地线路组</option>
{lineGroupOptions.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</Select>
</Field>
<Field label="计费周期(秒)">
<Input type="number" min="1" value={gatewayForm.billingCycleSec} onChange={(event) => setGatewayForm({ ...gatewayForm, billingCycleSec: event.target.value })} />
</Field>
<Field label="周期内费率">
<Input value={gatewayForm.cycleRate} onChange={(event) => setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="0.000000" />
</Field>
<Field label="主叫匹配">
<Select value={gatewayForm.callerMatchMode} onChange={(event) => setGatewayForm({ ...gatewayForm, callerMatchMode: event.target.value, callerPrefixes: '' })}>
<option value="ANY">任意号码</option>
<option value="PREFIXES">指定前缀</option>
</Select>
</Field>
{gatewayForm.callerMatchMode === 'PREFIXES' ? (
<Field label="主叫前缀">
<Textarea rows={3} value={gatewayForm.callerPrefixes} onChange={(event) => setGatewayForm({ ...gatewayForm, callerPrefixes: event.target.value })} placeholder="每行一个前缀" />
</Field>
) : null}
<Field label="被叫业务前缀">
<Select value={gatewayForm.calleeMatchMode} onChange={(event) => setGatewayForm({ ...gatewayForm, calleeMatchMode: event.target.value, businessPrefixIds: [] })}>
<option value="ANY">任意号码</option>
<option value="BUSINESS_PREFIXES">指定业务前缀</option>
</Select>
</Field>
{gatewayForm.calleeMatchMode === 'BUSINESS_PREFIXES' ? (
<div className="checkbox-grid">
{businessPrefixOptions.map((prefix) => (
<Checkbox
key={prefix.id}
checked={gatewayForm.businessPrefixIds.includes(prefix.id)}
onChange={(checked) => setGatewayForm((current) => ({
...current,
businessPrefixIds: checked
? [...current.businessPrefixIds, prefix.id]
: current.businessPrefixIds.filter((id) => id !== prefix.id),
}))}
>
{prefix.prefix} / {prefix.name}
</Checkbox>
))}
</div>
) : null}
<div className="modal-actions">
<Button type="button" variant="outline" disabled={submitting} onClick={closeGatewayModal}>取消</Button>
<Button type="submit" disabled={submitting}>{submitting ? '保存中' : editingGateway ? '保存修改' : '保存网关'}</Button>
</div>
</form>
</Modal>
) : null}
{gatewayConfirm ? (
<ConfirmDialog
title={gatewayConfirm.type === 'delete' ? '删除客户网关确认' : `${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}客户网关确认`}
confirmLabel={gatewayConfirm.type === 'delete' ? '确认删除' : `确认${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}`}
confirmVariant={gatewayConfirm.type === 'delete' ? 'danger' : 'primary'}
busy={submitting}
onCancel={() => setGatewayConfirm(null)}
onConfirm={() => {
const { type, row } = gatewayConfirm;
setGatewayConfirm(null);
return type === 'delete' ? void deleteGateway(row) : void toggleGatewayStatus(row);
}}
>
{gatewayConfirm.type === 'delete' ? (
<p>确认删除客户网关{gatewayConfirm.row.name}删除后该网关不会再参与客户呼入匹配</p>
) : (
<p>确认{gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}客户网关{gatewayConfirm.row.name}</p>
)}
</ConfirmDialog>
) : null}
{strategyGatewayData ? (
<Drawer title={`${strategyGatewayData.name} 策略配置`} aside={<Badge tone="info">{strategyGatewayData.id}</Badge>} onClose={closePolicyDrawer}>
<div className="drawer-toolbar">
<div>
<strong>{strategyPolicies.length} 条策略</strong>
<span>按优先级从小到大匹配主叫和被叫条件可并存</span>
</div>
{canManage ? <Button icon={<Icon type="plus" />} onClick={openCreatePolicy}>添加策略</Button> : null}
</div>
<SimpleTable rows={strategyPolicies} columns={[
{ key: 'priority', label: '优先级' },
{ key: 'name', label: '策略名称' },
{ key: 'callerMatch', label: '主叫匹配', render: (row) => formatMatch(row.callerMode, row.callerValue) },
{ key: 'calleeMatch', label: '被叫匹配', render: (row) => formatMatch(row.calleeMode, row.calleeValue) },
{ key: 'routeGroup', label: '呼叫至线路群组' },
{ key: 'status', label: '状态', status: true },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditPolicy(row)}>编辑</Button> : null}
{canManage ? <Button size="sm" variant="outline" onClick={() => movePolicy(row.id, -1)}>上移</Button> : null}
{canManage ? <Button size="sm" variant="outline" onClick={() => movePolicy(row.id, 1)}>下移</Button> : null}
{canManage ? (
<Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => togglePolicyStatus(row.id)}>
{row.status === '启用' ? '停用' : '启用'}
</Button>
) : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeletePolicyTarget(row)}>删除</Button> : '-'}
</div>
),
},
]} />
</Drawer>
) : null}
{policyModalOpen ? (
<Modal title={editingPolicy ? '编辑策略' : '添加策略'} onClose={closePolicyModal} size="lg">
<form className="strategy-form" onSubmit={submitPolicy}>
<Field label={<span>策略名称 <span className="required-star">*</span></span>}>
<Input value={policyForm.name} onChange={(event) => setPolicyForm({ ...policyForm, name: event.target.value })} placeholder="例如 华东移动号码优先" required />
</Field>
<div className="match-grid">
<div className="match-card">
<strong>主叫号码匹配</strong>
<Field label="匹配方式">
<Select value={policyForm.callerMode} onChange={(event) => setPolicyForm({ ...policyForm, callerMode: event.target.value, callerValue: '' })}>
<option value="any">不限</option>
<option value="equals">等于指定号码</option>
<option value="prefix">按前缀匹配</option>
</Select>
</Field>
{policyForm.callerMode !== 'any' ? (
<Field label={<span>主叫号码 <span className="required-star">*</span></span>}>
<Input value={policyForm.callerValue} onChange={(event) => setPolicyForm({ ...policyForm, callerValue: event.target.value })} placeholder={policyForm.callerMode === 'equals' ? '例如 02160010001' : '例如 0216001'} required />
</Field>
) : null}
</div>
<div className="match-card">
<strong>被叫号码匹配</strong>
<Field label="匹配方式">
<Select value={policyForm.calleeMode} onChange={(event) => setPolicyForm({ ...policyForm, calleeMode: event.target.value, calleeValue: '' })}>
<option value="any">不限</option>
<option value="equals">等于指定号码</option>
<option value="prefix">按前缀匹配</option>
</Select>
</Field>
{policyForm.calleeMode !== 'any' ? (
<Field label={<span>被叫号码 <span className="required-star">*</span></span>}>
<Input value={policyForm.calleeValue} onChange={(event) => setPolicyForm({ ...policyForm, calleeValue: event.target.value })} placeholder={policyForm.calleeMode === 'equals' ? '例如 13800138000' : '例如 13/15/18'} required />
</Field>
) : null}
</div>
</div>
<div className="sub-toolbar">
<Field label={<span>呼叫至线路群组 <span className="required-star">*</span></span>}>
<Select value={policyForm.routeGroup} onChange={(event) => setPolicyForm({ ...policyForm, routeGroup: event.target.value })} required>
{routeGroups.map((group) => <option key={group.id}>{group.name}</option>)}
</Select>
</Field>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closePolicyModal}>取消</Button>
<Button type="submit">{editingPolicy ? '保存修改' : '保存策略'}</Button>
</div>
</form>
</Modal>
) : null}
{deletePolicyTarget ? (
<ConfirmDialog
title="删除策略确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeletePolicyTarget(null)}
onConfirm={() => deletePolicy(deletePolicyTarget.id)}
>
<p>确认删除策略{deletePolicyTarget.name}删除后当前网关的策略优先级会自动重排</p>
</ConfirmDialog>
) : null}
</>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useState } from 'react';
import { Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
import { gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateCustomer, onUpdateCustomer, onDeleteCustomer, onRechargeCustomer }) {
const [showCreateCustomer, setShowCreateCustomer] = useState(false);
const [editingCustomer, setEditingCustomer] = useState(null);
const [rechargeCustomer, setRechargeCustomer] = useState(null);
const [deleteCustomerTarget, setDeleteCustomerTarget] = useState(null);
const [newCustomer, setNewCustomer] = useState({ name: '', contact: '', phone: '', email: '' });
const [editCustomerForm, setEditCustomerForm] = useState({ name: '', contact: '', phone: '', email: '' });
const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' });
const [actionError, setActionError] = useState('');
const canManageCustomers = can('customers.manage');
const canManageRecharges = can('recharges.manage');
const openEditCustomer = (customer) => {
setEditingCustomer(customer);
setEditCustomerForm({
name: customer.name || '',
contact: customer.contact === '-' ? '' : customer.contact || '',
phone: customer.phone === '-' ? '' : customer.phone || '',
email: customer.email === '-' ? '' : customer.email || '',
});
};
const closeEditCustomer = () => {
setEditingCustomer(null);
setEditCustomerForm({ name: '', contact: '', phone: '', email: '' });
};
const openRechargeCustomer = (customer) => {
setRechargeCustomer(customer);
setRechargeForm({ amount: '', remark: '' });
};
const closeRechargeCustomer = () => {
setRechargeCustomer(null);
setRechargeForm({ amount: '', remark: '' });
};
const parseMoney = (value) => Number(String(value).replace(/[^\d.-]/g, '')) || 0;
const formatMoney = (value) => `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const submitCustomer = async (event) => {
event.preventDefault();
if (!newCustomer.name.trim()) return;
setActionError('');
try {
if (onCreateCustomer) {
await onCreateCustomer({
name: newCustomer.name.trim(),
contactName: newCustomer.contact.trim() || undefined,
phone: newCustomer.phone.trim() || undefined,
email: newCustomer.email.trim() || undefined,
billingMode: 'PREPAID',
creditLimit: '0.000000',
minBalance: '0.000000',
});
} else {
const nextIndex = customerRows.length + 1;
setCustomerRows([...customerRows, { id: `C${String(1000 + nextIndex)}`, name: newCustomer.name.trim(), contact: newCustomer.contact.trim() || '-', phone: newCustomer.phone.trim() || '-', email: newCustomer.email.trim() || '-', domain: '-', auth: '待配置', status: '启用', balance: '¥0.00', credit: '¥0', billing: '待配置', routeGroup: '待配置', gateways: 0, createdAt: '2026-06-18' }]);
}
setNewCustomer({ name: '', contact: '', phone: '', email: '' });
setShowCreateCustomer(false);
} catch (error) {
setActionError(explainApiError(error));
}
};
const submitEditCustomer = async (event) => {
event.preventDefault();
if (!editCustomerForm.name.trim() || !editingCustomer) return;
setActionError('');
try {
if (onUpdateCustomer) {
await onUpdateCustomer(editingCustomer.id, {
name: editCustomerForm.name.trim(),
contactName: editCustomerForm.contact.trim() || null,
phone: editCustomerForm.phone.trim() || null,
email: editCustomerForm.email.trim() || null,
});
} else {
setCustomerRows(customerRows.map((customer) => (
customer.id === editingCustomer.id ? { ...customer, name: editCustomerForm.name.trim(), contact: editCustomerForm.contact.trim() || '-', phone: editCustomerForm.phone.trim() || '-', email: editCustomerForm.email.trim() || '-' } : customer
)));
}
closeEditCustomer();
} catch (error) {
setActionError(explainApiError(error));
}
};
const submitRecharge = async (event) => {
event.preventDefault();
const amount = Number(rechargeForm.amount);
if (!rechargeCustomer || !Number.isFinite(amount) || amount <= 0) return;
setActionError('');
try {
if (onRechargeCustomer) {
await onRechargeCustomer(rechargeCustomer.id, {
amount: amount.toFixed(2),
remark: rechargeForm.remark.trim() || undefined,
});
} else {
const beforeBalance = parseMoney(rechargeCustomer.balance);
const afterBalance = beforeBalance + amount;
setCustomerRows(customerRows.map((customer) => (
customer.id === rechargeCustomer.id ? { ...customer, balance: formatMoney(afterBalance) } : customer
)));
addRechargeRecord({ id: `RCG-${Date.now()}`, type: 'customer', owner: rechargeCustomer.name, amount: formatMoney(amount), beforeBalance: formatMoney(beforeBalance), afterBalance: formatMoney(afterBalance), remark: rechargeForm.remark.trim() || '-', operator: '运营管理员', time: new Date().toLocaleString('zh-CN', { hour12: false }), status: '成功' });
}
closeRechargeCustomer();
} catch (error) {
setActionError(explainApiError(error));
}
};
const deleteCustomer = async (customer) => {
setActionError('');
try {
if (onDeleteCustomer) {
await onDeleteCustomer(customer.id);
} else {
if ((customer.gateways ?? 0) > 0) {
throw new Error('该客户仍有关联客户网关,不能删除。');
}
setCustomerRows((rows) => rows.filter((item) => item.id !== customer.id));
}
setDeleteCustomerTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
return (
<>
<PageTitle
title="客户管理"
desc="客户开通、SIP 账号/IP 白名单、费率方案、线路组、余额授信和操作日志。"
actions={canManageCustomers ? <Button icon={<Icon type="plus" />} onClick={() => setShowCreateCustomer(true)}>新增客户</Button> : null}
/>
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
<Toolbar>
<Field label="客户名称"><Input placeholder="搜索客户名称 / 域名" /></Field>
<Field label="状态"><Select defaultValue="all"><option value="all">全部状态</option><option>启用</option><option>观察</option><option>停用</option></Select></Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<section className="master-detail">
<Panel title="客户列表" className="main-list wide-panel">
<SimpleTable
rows={customerRows}
columns={[
{ key: 'id', label: '客户 ID', width: '108px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '180px', className: 'table-cell-compact' },
{ key: 'balance', label: '余额' },
{ key: 'credit', label: '授信额度' },
{ key: 'status', label: '状态', status: true },
{ key: 'gateways', label: '客户网关数' },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManageCustomers ? <Button size="sm" variant="outline" onClick={() => openEditCustomer(row)}>编辑</Button> : null}
{canManageRecharges ? <Button size="sm" variant="secondary" onClick={() => openRechargeCustomer(row)}>充值</Button> : null}
{canManageCustomers ? <Button size="sm" variant="danger" onClick={() => setDeleteCustomerTarget(row)}>删除</Button> : null}
{!canManageCustomers && !canManageRecharges ? '-' : null}
</div>
),
},
]}
/>
</Panel>
</section>
{showCreateCustomer ? (
<Modal title="新增客户" onClose={() => setShowCreateCustomer(false)} size="sm">
<form className="modal-form" onSubmit={submitCustomer}>
<Field label={<span>客户名称 <span className="required-star">*</span></span>}>
<Input value={newCustomer.name} onChange={(event) => setNewCustomer({ ...newCustomer, name: event.target.value })} placeholder="请输入企业名称" required />
</Field>
<Field label="联系人">
<Input value={newCustomer.contact} onChange={(event) => setNewCustomer({ ...newCustomer, contact: event.target.value })} placeholder="请输入联系人" />
</Field>
<Field label="联系电话">
<Input value={newCustomer.phone} onChange={(event) => setNewCustomer({ ...newCustomer, phone: event.target.value })} placeholder="请输入联系电话" />
</Field>
<Field label="邮箱">
<Input type="email" value={newCustomer.email} onChange={(event) => setNewCustomer({ ...newCustomer, email: event.target.value })} placeholder="请输入邮箱" />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={() => setShowCreateCustomer(false)}>取消</Button>
<Button type="submit">保存客户</Button>
</div>
</form>
</Modal>
) : null}
{rechargeCustomer ? (
<Modal title={`${rechargeCustomer.name} 充值`} onClose={closeRechargeCustomer} size="sm">
<form className="modal-form" onSubmit={submitRecharge}>
<KeyValue label="当前余额" value={rechargeCustomer.balance} />
<Field label={<span>充值金额 <span className="required-star">*</span></span>}>
<Input type="number" min="0.01" step="0.01" value={rechargeForm.amount} onChange={(event) => setRechargeForm({ ...rechargeForm, amount: event.target.value })} placeholder="请输入充值金额" required />
</Field>
<Field label="备注">
<Textarea rows="4" value={rechargeForm.remark} onChange={(event) => setRechargeForm({ ...rechargeForm, remark: event.target.value })} placeholder="请输入备注" />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeRechargeCustomer}>取消</Button>
<Button type="submit">确认充值</Button>
</div>
</form>
</Modal>
) : null}
{editingCustomer ? (
<Modal title="编辑客户" onClose={closeEditCustomer} size="sm">
<form className="modal-form" onSubmit={submitEditCustomer}>
<Field label={<span>客户名称 <span className="required-star">*</span></span>}>
<Input value={editCustomerForm.name} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, name: event.target.value })} placeholder="请输入客户名称" required />
</Field>
<Field label="联系人">
<Input value={editCustomerForm.contact} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, contact: event.target.value })} placeholder="请输入联系人" />
</Field>
<Field label="联系电话">
<Input value={editCustomerForm.phone} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, phone: event.target.value })} placeholder="请输入联系电话" />
</Field>
<Field label="邮箱">
<Input type="email" value={editCustomerForm.email} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, email: event.target.value })} placeholder="请输入邮箱" />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeEditCustomer}>取消</Button>
<Button type="submit">保存修改</Button>
</div>
</form>
</Modal>
) : null}
{deleteCustomerTarget ? (
<ConfirmDialog
title="删除客户确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteCustomerTarget(null)}
onConfirm={() => void deleteCustomer(deleteCustomerTarget)}
>
<p>确认删除客户{deleteCustomerTarget.name}删除后该客户将不再出现在客户列表中</p>
</ConfirmDialog>
) : null}
</>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx';
import { formatCurrency } from '../utils/formatters.js';
import { metrics, callTrend, answerTrend } from '../fixtures/devFixtures.js';
function dashboardMetrics(summary) {
if (!summary) {
return metrics;
}
return [
{ label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' },
{ label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: 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: formatCurrency(summary.money.customerFee), delta: '今日', tone: 'neutral' },
{ label: '供应商成本', value: formatCurrency(summary.money.vendorCost), 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.entities.activeCustomers), 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.quality.pendingReviews), delta: '录音', tone: summary.quality.pendingReviews ? 'warn' : 'neutral' },
];
}
export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }) {
const trendBuckets = dashboardTrends?.buckets || [];
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : callTrend;
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : answerTrend;
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
return (
<>
<PageTitle
title="概览 Dashboard"
desc="展示平台整体运行状态,覆盖通话、收入、成本、注册、节点和质检待办。"
actions={<Button icon={<Icon type="reload" />} onClick={refreshApi}>刷新指标</Button>}
/>
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
<section className="metric-grid">
{dashboardMetrics(dashboardSummary).map((metric) => (
<div className="metric-card" key={metric.label}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
<em className={`metric-${metric.tone}`}>{metric.delta}</em>
</div>
))}
</section>
<section className="content-grid">
<Panel title="最近 24 小时通话量趋势" aside={<Badge tone="info">OpenSIPS acc/CDR</Badge>}>
<MiniBarChart data={callTrendData.length ? callTrendData : [0]} />
</Panel>
<Panel title="最近 24 小时接通率趋势" aside={<Badge tone="info">dialog statistics</Badge>}>
<LineChart data={answerTrendData.length ? answerTrendData : [0, 0]} />
</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>
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Badge, Button, Checkbox } from '../components/ui.jsx';
import { PageTitle, Panel, SimpleTable } from '../components/layout.jsx';
import { opsItems } from '../fixtures/devFixtures.js';
export function MonitoringPage() {
return (
<>
<PageTitle title="监控告警" desc="OpenSIPS、RTPEngine、端口监听、CDR 堆积、计费队列、录音入库和磁盘空间。" actions={<Button>新增告警规则</Button>} />
<section className="content-grid">
<Panel title="监控项" className="wide-panel">
<SimpleTable rows={opsItems} columns={[{ key: 'name', label: '监控项' }, { key: 'target', label: '目标' }, { key: 'status', label: '状态', status: true }, { key: 'value', label: '当前值' }]} />
</Panel>
<Panel title="告警方式">
<div className="setting-list">
<Checkbox label="邮件" checked readOnly />
<Checkbox label="企业微信/钉钉/飞书" checked readOnly />
<Checkbox label="短信" checked={false} readOnly />
</div>
</Panel>
<Panel title="推荐工具">
<div className="tag-cloud">{['Prometheus', 'Grafana', 'Loki', 'ELK', 'Alertmanager', 'Monit'].map((tag) => <Badge key={tag} tone="brand">{tag}</Badge>)}</div>
</Panel>
</section>
</>
);
}
+307
View File
@@ -0,0 +1,307 @@
import { useEffect, useState } from 'react';
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 { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
export function NumberLibraryPage({ can = () => true }) {
const [activeTab, setActiveTab] = useState('cities');
const [rows, setRows] = useState(emptyNumberLibraryRows);
const [totals, setTotals] = useState(emptyNumberLibraryTotals);
const [filters, setFilters] = useState({
cities: { keyword: '' },
phoneSegments: { segment7: '', cityCode: '', carrier: 'all' },
areaCodes: { areaCode: '', cityCode: '' },
carrierPrefixRules: { prefix: '', carrier: 'all' },
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [importTarget, setImportTarget] = useState(null);
const [importText, setImportText] = useState('');
const [importing, setImporting] = useState(false);
const canManage = can('number_library.manage');
const readTab = async (tab) => {
const params = filters[tab] || {};
if (tab === 'cities') {
const payload = await api.numberLibraryCities(params);
return normalizeNumberLibraryList(payload, (item) => ({
id: item.code,
code: item.code,
provinceName: item.provinceName,
cityName: item.cityName,
cityLevel: item.cityLevel || '-',
status: zhStatus(item.status),
effectiveFrom: formatDate(item.effectiveFrom),
effectiveTo: formatDate(item.effectiveTo),
}));
}
if (tab === 'phoneSegments') {
const payload = await api.numberLibraryPhoneSegments(params);
return normalizeNumberLibraryList(payload, (item) => ({
id: item.segment7,
segment7: item.segment7,
provinceName: item.provinceName,
cityCode: item.cityCode,
cityName: item.cityName,
carrier: carrierLabel(item.carrier),
numberType: item.numberType || '-',
source: item.source || '-',
batchId: item.batchId || '-',
}));
}
if (tab === 'areaCodes') {
const payload = await api.numberLibraryAreaCodes(params);
return normalizeNumberLibraryList(payload, (item) => ({
id: item.areaCode,
areaCode: item.areaCode,
provinceName: item.provinceName,
cityCode: item.cityCode,
cityName: item.cityName,
source: item.source || '-',
batchId: item.batchId || '-',
}));
}
const payload = await api.numberLibraryCarrierPrefixRules(params);
return normalizeNumberLibraryList(payload, (item) => ({
id: item.prefix,
prefix: item.prefix,
carrier: carrierLabel(item.carrier),
priority: item.priority,
source: item.source || '-',
batchId: item.batchId || '-',
effectiveFrom: formatDate(item.effectiveFrom),
effectiveTo: formatDate(item.effectiveTo),
}));
};
const loadTab = async (tab = activeTab) => {
setLoading(true);
setError('');
try {
const result = await readTab(tab);
setRows((current) => ({ ...current, [tab]: result.rows }));
setTotals((current) => ({ ...current, [tab]: result.total }));
} catch (loadError) {
setError(explainApiError(loadError));
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadTab('cities');
void loadTab('phoneSegments');
void loadTab('areaCodes');
void loadTab('carrierPrefixRules');
}, []);
const updateFilter = (key, value) => {
setFilters((current) => ({
...current,
[activeTab]: { ...current[activeTab], [key]: value },
}));
};
const openImport = (tab) => {
setImportTarget(tab);
setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2));
setMessage('');
setError('');
};
const submitImport = async (event) => {
event.preventDefault();
if (!importTarget) return;
setImporting(true);
setError('');
try {
const parsed = JSON.parse(importText);
const items = Array.isArray(parsed) ? parsed : parsed.items;
if (!Array.isArray(items) || items.length === 0) {
throw new Error('导入内容必须是非空数组。');
}
const importers = {
cities: api.importNumberLibraryCities,
phoneSegments: api.importNumberLibraryPhoneSegments,
areaCodes: api.importNumberLibraryAreaCodes,
carrierPrefixRules: api.importNumberLibraryCarrierPrefixRules,
};
const result = await importers[importTarget](items);
setMessage(`导入完成:新增 ${result?.created ?? 0} 条,更新 ${result?.updated ?? 0} 条。`);
setImportTarget(null);
setImportText('');
await loadTab(importTarget);
} catch (submitError) {
setError(explainApiError(submitError));
} finally {
setImporting(false);
}
};
const renderFilters = () => {
const current = filters[activeTab];
if (activeTab === 'cities') {
return (
<>
<Field label="省份/城市"><Input value={current.keyword} onChange={(event) => updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /></Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
</>
);
}
if (activeTab === 'phoneSegments') {
return (
<>
<Field label="前 7 位号段"><Input value={current.segment7} onChange={(event) => updateFilter('segment7', event.target.value)} placeholder="如 1380013" /></Field>
<Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
<Field label="运营商">
<Select value={current.carrier} onChange={(event) => updateFilter('carrier', event.target.value)}>
<option value="all">全部</option>
<option value="MOBILE">移动</option>
<option value="UNICOM">联通</option>
<option value="TELECOM">电信</option>
<option value="BROADCAST">广电</option>
<option value="MVNO">虚拟运营商</option>
<option value="UNKNOWN">未知</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
</>
);
}
if (activeTab === 'areaCodes') {
return (
<>
<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>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
</>
);
}
return (
<>
<Field label="号码前缀"><Input value={current.prefix} onChange={(event) => updateFilter('prefix', event.target.value)} placeholder="如 138" /></Field>
<Field label="运营商">
<Select value={current.carrier} onChange={(event) => updateFilter('carrier', event.target.value)}>
<option value="all">全部</option>
<option value="MOBILE">移动</option>
<option value="UNICOM">联通</option>
<option value="TELECOM">电信</option>
<option value="BROADCAST">广电</option>
<option value="MVNO">虚拟运营商</option>
<option value="UNKNOWN">未知</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
</>
);
};
const renderTable = (tab) => {
if (tab === 'cities') {
return (
<SimpleTable rows={rows.cities} columns={[
{ key: 'code', label: '地级市代码', width: '110px' },
{ key: 'provinceName', label: '省份', width: '120px' },
{ key: 'cityName', label: '地级市', width: '140px' },
{ key: 'cityLevel', label: '级别', width: '120px' },
{ key: 'status', label: '状态', width: '90px', status: true },
{ key: 'effectiveFrom', label: '生效时间' },
{ key: 'effectiveTo', label: '失效时间' },
]} />
);
}
if (tab === 'phoneSegments') {
return (
<SimpleTable rows={rows.phoneSegments} columns={[
{ key: 'segment7', label: '前 7 位', width: '110px' },
{ key: 'provinceName', label: '省份', width: '120px' },
{ key: 'cityCode', label: '地级市代码', width: '120px' },
{ key: 'cityName', label: '地级市', width: '140px' },
{ key: 'carrier', label: '运营商', width: '100px' },
{ key: 'numberType', label: '号码类型', width: '110px' },
{ key: 'batchId', label: '批次' },
{ key: 'source', label: '来源' },
]} />
);
}
if (tab === 'areaCodes') {
return (
<SimpleTable rows={rows.areaCodes} columns={[
{ key: 'areaCode', label: '区号', width: '100px' },
{ key: 'provinceName', label: '省份', width: '120px' },
{ key: 'cityCode', label: '地级市代码', width: '120px' },
{ key: 'cityName', label: '地级市', width: '140px' },
{ key: 'batchId', label: '批次' },
{ key: 'source', label: '来源' },
]} />
);
}
return (
<SimpleTable rows={rows.carrierPrefixRules} columns={[
{ key: 'prefix', label: '前缀', width: '100px' },
{ key: 'carrier', label: '运营商', width: '110px' },
{ key: 'priority', label: '优先级', width: '90px' },
{ key: 'batchId', label: '批次' },
{ key: 'source', label: '来源' },
{ key: 'effectiveFrom', label: '生效时间' },
{ key: 'effectiveTo', label: '失效时间' },
]} />
);
};
const importTitle = importTarget ? numberLibraryTabs.find((tab) => tab.value === importTarget)?.label : '';
return (
<>
<PageTitle
title="号码库"
desc="维护地级市、手机前 7 位号段、固话区号和运营商前缀规则。"
actions={canManage ? <Button icon={<Icon type="export" />} onClick={() => openImport(activeTab)}>批量导入</Button> : null}
/>
<ApiNotice loading={loading} error={error} onRetry={() => void loadTab(activeTab)} />
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
<Tabs
value={activeTab}
onChange={(value) => {
setActiveTab(value);
if (rows[value].length === 0) {
void loadTab(value);
}
}}
tabs={numberLibraryTabs.map((tab) => ({
...tab,
label: `${tab.label}${totals[tab.value]}`,
content: (
<Panel
title={tab.label}
className="wide-panel"
aside={<Button size="sm" variant="outline" icon={<Icon type="reload" />} onClick={() => void loadTab(tab.value)}>刷新</Button>}
>
{tab.value === activeTab ? (
<Toolbar>
{renderFilters()}
</Toolbar>
) : null}
{renderTable(tab.value)}
</Panel>
),
}))}
/>
{importTarget ? (
<Modal title={`导入${importTitle}`} onClose={importing ? () => {} : () => setImportTarget(null)}>
<form className="modal-form" onSubmit={submitImport}>
<Field label="JSON 数据">
<Textarea rows={12} value={importText} onChange={(event) => setImportText(event.target.value)} />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" disabled={importing} onClick={() => setImportTarget(null)}>取消</Button>
<Button type="submit" disabled={importing}>{importing ? '导入中' : '确认导入'}</Button>
</div>
</form>
</Modal>
) : null}
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useMemo, useState } from 'react';
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
import { operationLogRows } from '../fixtures/devFixtures.js';
export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiError, refreshApi }) {
const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('all');
const [resultFilter, setResultFilter] = useState('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [detailLog, setDetailLog] = useState(null);
const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]);
const visibleLogs = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
return logRows.filter((log) => {
const matchesKeyword = !normalized || [log.user, log.username, log.action, log.object, log.ip].some((value) => value.toLowerCase().includes(normalized));
const logDate = log.time.slice(0, 10);
return matchesKeyword && (moduleFilter === 'all' || log.module === moduleFilter) && (resultFilter === 'all' || log.result === resultFilter) && (!startDate || logDate >= startDate) && (!endDate || logDate <= endDate);
});
}, [endDate, keyword, logRows, moduleFilter, resultFilter, startDate]);
const resetFilters = () => { setKeyword(''); setModuleFilter('all'); setResultFilter('all'); setStartDate(''); setEndDate(''); };
return (
<>
<PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
<Toolbar>
<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={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>
<Field label="结束日期"><Input type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></Field>
<Button variant="outline" onClick={resetFilters}>重置</Button>
</Toolbar>
<Panel title="日志列表" aside={<Badge tone="neutral"> {visibleLogs.length} </Badge>} className="wide-panel">
<SimpleTable rows={visibleLogs} columns={[
{ key: 'time', label: '操作时间' },
{ key: 'user', label: '操作用户' },
{ key: 'module', label: '功能模块' },
{ key: 'action', label: '操作类型' },
{ key: 'object', label: '操作对象' },
{ key: 'result', label: '结果', status: true },
{ key: 'ip', label: '操作 IP' },
{ key: 'actions', label: '操作', render: (row) => <Button size="sm" variant="outline" onClick={() => setDetailLog(row)}>查看详情</Button> },
]} />
</Panel>
{detailLog ? (
<Drawer title="操作日志详情" aside={<Badge tone={toneForStatus(detailLog.result)}>{detailLog.result}</Badge>} onClose={() => setDetailLog(null)}>
<div className="detail-stack">
<KeyValue label="日志 ID" value={detailLog.id} />
<KeyValue label="操作时间" value={detailLog.time} />
<KeyValue label="操作用户" value={`${detailLog.user}${detailLog.username}`} />
<KeyValue label="功能模块" value={detailLog.module} />
<KeyValue label="操作类型" value={detailLog.action} />
<KeyValue label="操作对象" value={detailLog.object} />
<KeyValue label="执行结果" value={detailLog.result} />
<KeyValue label="操作 IP" value={detailLog.ip} />
<KeyValue label="客户端" value={detailLog.userAgent} />
</div>
<section className="log-summary"><strong>操作摘要</strong><p>{detailLog.summary}</p></section>
</Drawer>
) : null}
</>
);
}
+418
View File
@@ -0,0 +1,418 @@
import { useEffect, useRef, useState } from 'react';
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 { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true }) {
const [ruleRows, setRuleRows] = useState([]);
const [recordingRows, setRecordingRows] = useState([]);
const [qualityLoading, setQualityLoading] = useState(false);
const [qualityError, setQualityError] = useState('');
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '100' });
const [showRules, setShowRules] = useState(false);
const [editingRule, setEditingRule] = useState(undefined);
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
const [deleteRuleTarget, setDeleteRuleTarget] = useState(null);
const [ruleBusy, setRuleBusy] = useState(false);
const [ruleError, setRuleError] = useState('');
const [detailRecording, setDetailRecording] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [reviewForm, setReviewForm] = useState({ result: '通过', issue: '', score: '90', issueTags: '' });
const [reviewSaving, setReviewSaving] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [autoPlay, setAutoPlay] = useState(false);
const [playbackProgress, setPlaybackProgress] = useState(0);
const [playbackUrl, setPlaybackUrl] = useState('');
const [playbackError, setPlaybackError] = useState('');
const [playbackLoading, setPlaybackLoading] = useState(false);
const [saveFeedback, setSaveFeedback] = useState('');
const audioRef = useRef(null);
const canManageQuality = can('quality.manage');
const canPlayRecordings = can('recordings.play');
const detailRecordingIndex = detailRecording ? recordingRows.findIndex((recording) => recording.id === detailRecording.id) : -1;
const closePlayback = () => {
setIsPlaying(false);
setPlaybackProgress(0);
setPlaybackError('');
setPlaybackLoading(false);
if (playbackUrl) {
URL.revokeObjectURL(playbackUrl);
setPlaybackUrl('');
}
};
const refreshQuality = async (nextFilter = recordingFilter) => {
setQualityLoading(true);
setQualityError('');
try {
const params = {
limit: nextFilter.limit,
reviewStatus: nextFilter.reviewStatus,
};
const [recordingList, ruleList] = await Promise.all([
api.recordings(params),
api.qualityRules(),
]);
setRecordingRows((recordingList || []).map(normalizeRecording));
setRuleRows((ruleList || []).map(normalizeQualityRule));
} catch (error) {
setQualityError(explainApiError(error));
} finally {
setQualityLoading(false);
}
};
useEffect(() => {
void refreshQuality();
}, []);
useEffect(() => () => {
if (playbackUrl) URL.revokeObjectURL(playbackUrl);
}, [playbackUrl]);
useEffect(() => {
if (!audioRef.current || !playbackUrl) return;
if (isPlaying) {
audioRef.current.play().catch(() => setIsPlaying(false));
} else {
audioRef.current.pause();
}
}, [isPlaying, playbackUrl]);
useEffect(() => {
if (!isPlaying || !detailRecording || playbackUrl) return undefined;
const timer = window.setInterval(() => setPlaybackProgress((progress) => Math.min(100, progress + 5)), 120);
return () => window.clearInterval(timer);
}, [detailRecording?.id, isPlaying, playbackUrl]);
useEffect(() => {
if (playbackUrl || playbackProgress < 100 || !detailRecording) return;
if (autoPlay && detailRecordingIndex < recordingRows.length - 1) {
const nextRecording = recordingRows[detailRecordingIndex + 1];
void openRecordingDetail(nextRecording, { keepAutoPlay: true, startPlayback: true });
return;
}
setIsPlaying(false);
}, [autoPlay, detailRecording, detailRecordingIndex, playbackProgress, playbackUrl, recordingRows]);
const openCreateRule = () => {
setEditingRule(null);
setRuleError('');
setRuleForm({ ...emptySamplingRuleForm, start: new Date().toISOString().slice(0, 10) });
};
const openEditRule = (rule) => {
setEditingRule(rule);
setRuleError('');
setRuleForm({
name: rule.name,
customerId: rule.customerId,
ratio: rule.ratio,
lineGroupId: rule.lineGroupId,
start: rule.start,
expiresAt: rule.expiresAt,
status: rule.status,
});
};
const closeRuleModal = () => {
setEditingRule(undefined);
setRuleForm(emptySamplingRuleForm);
setRuleError('');
};
const ruleBody = () => ({
name: ruleForm.name.trim(),
customerId: ruleForm.customerId || null,
lineGroupId: ruleForm.lineGroupId || null,
ratio: String(ruleForm.ratio),
status: ruleForm.status === '启用' ? 'ENABLED' : 'DISABLED',
effectiveAt: ruleForm.start ? new Date(`${ruleForm.start}T00:00:00`).toISOString() : undefined,
expiresAt: ruleForm.expiresAt ? new Date(`${ruleForm.expiresAt}T23:59:59`).toISOString() : null,
});
const submitRule = async (event) => {
event.preventDefault();
if (!ruleForm.name.trim()) return;
setRuleBusy(true);
setRuleError('');
try {
if (editingRule) {
await api.updateQualityRule(editingRule.id, ruleBody());
} else {
await api.createQualityRule(ruleBody());
}
await refreshQuality();
closeRuleModal();
} catch (error) {
setRuleError(explainApiError(error));
} finally {
setRuleBusy(false);
}
};
const toggleRuleStatus = async (rule) => {
setRuleBusy(true);
setRuleError('');
try {
if (rule.status === '启用') {
await api.disableQualityRule(rule.id);
} else {
await api.enableQualityRule(rule.id);
}
await refreshQuality();
} catch (error) {
setRuleError(explainApiError(error));
} finally {
setRuleBusy(false);
}
};
const deleteRule = async () => {
if (!deleteRuleTarget) return;
setRuleBusy(true);
setRuleError('');
try {
await api.deleteQualityRule(deleteRuleTarget.id);
await refreshQuality();
setDeleteRuleTarget(null);
} catch (error) {
setRuleError(explainApiError(error));
} finally {
setRuleBusy(false);
}
};
const setReviewDraft = (recording) => {
setReviewForm({
result: recording.result && recording.result !== '-' ? recording.result : '通过',
issue: recording.issue || '',
score: recording.score === '' || recording.score === null || recording.score === undefined ? '90' : String(recording.score),
issueTags: recording.issueTagsText || '',
});
};
const openRecordingDetail = async (recording, options = {}) => {
closePlayback();
setDetailRecording(recording);
setReviewDraft(recording);
setDetailLoading(true);
setIsPlaying(false);
setAutoPlay(Boolean(options.keepAutoPlay));
setPlaybackProgress(0);
setSaveFeedback('');
try {
const detail = normalizeRecording(await api.recordingDetail(recording.id));
setDetailRecording(detail);
setReviewDraft(detail);
setRecordingRows((rows) => rows.map((row) => (row.id === detail.id ? detail : row)));
if (options.startPlayback) {
await loadPlayback(detail);
}
} catch (error) {
setSaveFeedback(explainApiError(error));
} finally {
setDetailLoading(false);
}
};
const closeRecordingDetail = () => {
setDetailRecording(null);
closePlayback();
setSaveFeedback('');
};
const navigateRecording = (direction) => {
const nextIndex = detailRecordingIndex + direction;
if (nextIndex < 0 || nextIndex >= recordingRows.length) return;
const nextRecording = recordingRows[nextIndex];
void openRecordingDetail(nextRecording, { keepAutoPlay: autoPlay });
};
const loadPlayback = async (recording = detailRecording) => {
if (!recording || playbackLoading) return;
setPlaybackLoading(true);
setPlaybackError('');
try {
const blob = await api.recordingPlayback(recording.id);
if (playbackUrl) URL.revokeObjectURL(playbackUrl);
setPlaybackUrl(URL.createObjectURL(blob));
setIsPlaying(true);
setPlaybackProgress(0);
} catch (error) {
setPlaybackError(explainApiError(error));
} finally {
setPlaybackLoading(false);
}
};
const togglePlayback = async () => {
if (!playbackUrl) {
await loadPlayback();
return;
}
if (isPlaying) {
audioRef.current?.pause();
} else {
await audioRef.current?.play().catch(() => setPlaybackError('浏览器阻止了自动播放,请使用播放器控件开始试听。'));
}
setIsPlaying((playing) => !playing);
};
const handleAudioEnded = () => {
setIsPlaying(false);
setPlaybackProgress(100);
if (autoPlay && detailRecordingIndex < recordingRows.length - 1) {
const nextRecording = recordingRows[detailRecordingIndex + 1];
void openRecordingDetail(nextRecording, { keepAutoPlay: true, startPlayback: true });
}
};
const saveRecordingReview = async () => {
if (!detailRecording || reviewSaving) return;
setReviewSaving(true);
setSaveFeedback('');
try {
await api.saveRecordingReview(detailRecording.id, {
result: reviewResultValue(reviewForm.result),
score: reviewForm.score === '' ? null : Number(reviewForm.score),
notes: reviewForm.issue,
issueTags: reviewForm.issueTags.split(',').map((tag) => tag.trim()).filter(Boolean),
});
const [detail] = await Promise.all([
api.recordingDetail(detailRecording.id),
refreshQuality(),
]);
const normalized = normalizeRecording(detail);
setDetailRecording(normalized);
setReviewDraft(normalized);
setRecordingRows((rows) => rows.map((recording) => (recording.id === normalized.id ? normalized : recording)));
setSaveFeedback('质检结果已保存,并已刷新录音列表与详情。');
} catch (error) {
setSaveFeedback(explainApiError(error));
} finally {
setReviewSaving(false);
}
};
const changeRecordingFilter = (key, value) => {
const nextFilter = { ...recordingFilter, [key]: value };
setRecordingFilter(nextFilter);
void refreshQuality(nextFilter);
};
return (
<>
<PageTitle
title="质检中心"
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>}
/>
<ApiNotice loading={qualityLoading} error={qualityError} onRetry={() => void refreshQuality()} />
<Toolbar>
<Field label="质检状态">
<Select value={recordingFilter.reviewStatus} onChange={(event) => changeRecordingFilter('reviewStatus', event.target.value)}>
<option value="all">全部</option>
<option value="PENDING">待质检</option>
<option value="REVIEWED">已完成</option>
</Select>
</Field>
<Field label="读取条数">
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="500">500</option>
</Select>
</Field>
</Toolbar>
<Panel title="录音列表" aside={<Badge tone="neutral"> {recordingRows.length} 条录音</Badge>} className="wide-panel">
<SimpleTable rows={recordingRows} columns={[
{ key: 'callId', label: 'Call-ID' },
{ key: 'customer', label: '客户' },
{ key: 'caller', label: '主叫' },
{ key: 'callee', label: '被叫' },
{ key: 'business', label: '业务' },
{ key: 'time', label: '通话时间' },
{ key: 'duration', label: '时长' },
{ key: 'samplingText', label: '抽样状态', status: true },
{ key: 'review', label: '质检状态', status: true },
{ key: 'actions', label: '操作', render: (row) => <Button size="sm" variant="outline" onClick={() => void openRecordingDetail(row)}>录音详情</Button> },
]} />
</Panel>
{showRules ? (
<Drawer title="抽检规则" aside={<Badge tone="info">{ruleRows.length} 条规则</Badge>} onClose={() => setShowRules(false)}>
{ruleError ? <Alert title="规则操作失败" tone="warning">{ruleError}</Alert> : null}
<div className="drawer-toolbar">
<div><strong>规则管理</strong><span>按客户和线路设置录音抽检比例</span></div>
{canManageQuality ? <Button icon={<Icon type="plus" />} disabled={ruleBusy} onClick={openCreateRule}>新增抽检规则</Button> : null}
</div>
<SimpleTable rows={ruleRows} columns={[
{ key: 'name', label: '规则名称' },
{ key: 'customer', label: '客户' },
{ key: 'ratio', label: '抽检比例', render: (row) => `${row.ratio}%` },
{ key: 'route', label: '指定线路' },
{ key: 'start', label: '生效时间' },
{ key: 'status', label: '状态', status: true },
{ key: 'actions', label: '操作', render: (row) => <div className="table-actions">{canManageQuality ? <Button size="sm" variant="outline" disabled={ruleBusy} onClick={() => openEditRule(row)}>编辑</Button> : null}{canManageQuality ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} disabled={ruleBusy} onClick={() => void toggleRuleStatus(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}{canManageQuality ? <Button size="sm" variant="danger" disabled={ruleBusy} onClick={() => setDeleteRuleTarget(row)}>删除</Button> : '-'}</div> },
]} />
</Drawer>
) : null}
{editingRule !== undefined ? (
<Modal title={editingRule ? '编辑抽检规则' : '新增抽检规则'} onClose={closeRuleModal}>
{ruleError ? <Alert title="规则保存失败" tone="warning">{ruleError}</Alert> : null}
<form className="modal-form" onSubmit={submitRule}>
<div className="form-grid admin-form-grid">
<Field label={<span>规则名称 <span className="required-star">*</span></span>}><Input value={ruleForm.name} onChange={(event) => setRuleForm({ ...ruleForm, name: event.target.value })} placeholder="请输入规则名称" required /></Field>
<Field label="客户"><Select value={ruleForm.customerId} onChange={(event) => setRuleForm({ ...ruleForm, customerId: event.target.value })}><option value="">全部客户</option>{customerRows.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}</Select></Field>
<Field label={<span>抽检比例% <span className="required-star">*</span></span>}><Input type="number" min="0" max="100" step="0.01" value={ruleForm.ratio} onChange={(event) => setRuleForm({ ...ruleForm, ratio: event.target.value })} required /></Field>
<Field label="指定线路"><Select value={ruleForm.lineGroupId} onChange={(event) => setRuleForm({ ...ruleForm, lineGroupId: event.target.value })}><option value="">全部线路</option>{lineGroupRows.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}</Select></Field>
<Field label="生效时间"><Input type="date" value={ruleForm.start} onChange={(event) => setRuleForm({ ...ruleForm, start: event.target.value })} /></Field>
<Field label="失效时间"><Input type="date" value={ruleForm.expiresAt} onChange={(event) => setRuleForm({ ...ruleForm, expiresAt: event.target.value })} /></Field>
<Field label="状态"><Select value={ruleForm.status} onChange={(event) => setRuleForm({ ...ruleForm, status: event.target.value })}><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
</div>
<div className="modal-actions"><Button type="button" variant="outline" disabled={ruleBusy} onClick={closeRuleModal}>取消</Button><Button type="submit" disabled={ruleBusy}>{ruleBusy ? '保存中' : editingRule ? '保存修改' : '保存规则'}</Button></div>
</form>
</Modal>
) : null}
{deleteRuleTarget ? (
<ConfirmDialog
title="确认删除抽检规则"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteRuleTarget(null)}
onConfirm={() => void deleteRule()}
busy={ruleBusy}
>
<p>确认删除规则{deleteRuleTarget.name}删除后该规则将不再参与后续录音抽检</p>
</ConfirmDialog>
) : null}
{detailRecording ? (
<Drawer title="录音详情" aside={<StatusBadge>{detailRecording.review}</StatusBadge>} onClose={closeRecordingDetail}>
{detailLoading ? <Alert title="正在读取录音详情">正在加载最新质检记录与抽样结果</Alert> : null}
<div className="drawer-toolbar">
<div><strong>录音导航</strong><span> {detailRecordingIndex + 1} {recordingRows.length} </span></div>
<div className="table-actions"><Button variant="outline" disabled={detailRecordingIndex <= 0} onClick={() => navigateRecording(-1)}>上一个</Button><Button variant="outline" disabled={detailRecordingIndex >= recordingRows.length - 1} onClick={() => navigateRecording(1)}>下一个</Button></div>
</div>
<div className="detail-stack">
<KeyValue label="Call-ID" value={detailRecording.callId} />
<KeyValue label="客户" value={detailRecording.customer} />
<KeyValue label="主叫号码" value={detailRecording.caller} />
<KeyValue label="被叫号码" value={detailRecording.callee} />
<KeyValue label="业务" value={detailRecording.business} />
<KeyValue label="通话时间" value={detailRecording.time} />
<KeyValue label="录音时长" value={detailRecording.duration} />
<KeyValue label="抽样状态" value={detailRecording.samplingText} />
<KeyValue label="命中规则" value={detailRecording.samplingRuleText} />
</div>
<div className="audio-bar"><span style={{ background: `linear-gradient(90deg, var(--selected) ${playbackProgress}%, #dfe3ec ${playbackProgress}%)` }} /> <strong>{detailRecording.file}</strong></div>
<div className="drawer-toolbar"><div><strong>录音试听</strong><span>{!canPlayRecordings ? '当前账号没有录音播放权限。' : playbackError || (isPlaying ? '正在播放真实录音流' : playbackUrl ? '已读取录音,可使用播放器控制。' : '用于人工抽检、申诉复核和服务质量核查。')}</span></div><div className="table-actions">{canPlayRecordings ? <Button variant="secondary" disabled={playbackLoading} onClick={() => void togglePlayback()}>{playbackLoading ? '读取中' : isPlaying ? '暂停播放' : playbackUrl ? '继续播放' : '播放录音'}</Button> : null}{canPlayRecordings ? <Button variant={autoPlay ? 'secondary' : 'outline'} onClick={() => setAutoPlay((enabled) => !enabled)}>自动播放{autoPlay ? '开' : '关'}</Button> : null}</div></div>
{playbackUrl ? <audio ref={audioRef} className="recording-player" src={playbackUrl} controls autoPlay={isPlaying} onEnded={handleAudioEnded} onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)} onTimeUpdate={(event) => {
const audio = event.currentTarget;
if (Number.isFinite(audio.duration) && audio.duration > 0) {
setPlaybackProgress(Math.min(100, Math.round((audio.currentTime / audio.duration) * 100)));
}
}} /> : null}
{playbackError ? <Alert title="录音播放失败" tone="warning">{playbackError}</Alert> : null}
{canManageQuality ? <Field label="质检结果">
<Select value={reviewForm.result} onChange={(event) => setReviewForm({ ...reviewForm, result: event.target.value })}>
<option value="通过">通过</option>
<option value="有问题">有问题</option>
<option value="升级处理">升级处理</option>
</Select>
</Field> : null}
{canManageQuality ? <Field label="问题标签"><Input value={reviewForm.issueTags} onChange={(event) => setReviewForm({ ...reviewForm, issueTags: event.target.value })} placeholder="多个标签用逗号分隔" /></Field> : null}
{canManageQuality ? <Field label="问题标注"><Textarea rows="4" value={reviewForm.issue} onChange={(event) => setReviewForm({ ...reviewForm, issue: event.target.value })} placeholder="标注关键词、服务态度、合规风险" /></Field> : null}
{canManageQuality ? <Field label="评分"><Input type="number" min="0" max="100" value={reviewForm.score} onChange={(event) => setReviewForm({ ...reviewForm, score: event.target.value })} /></Field> : null}
{saveFeedback ? <Alert title={saveFeedback.includes('失败') || saveFeedback.includes('不可用') || saveFeedback.includes('failed') ? '操作提示' : '保存反馈'} tone={saveFeedback.includes('失败') || saveFeedback.includes('不可用') || saveFeedback.includes('failed') ? 'warning' : 'success'}>{saveFeedback}</Alert> : null}
<div className="drawer-actions"><Button variant="outline" onClick={closeRecordingDetail}>关闭</Button>{canManageQuality ? <Button disabled={reviewSaving} onClick={() => void saveRecordingReview()}>{reviewSaving ? '保存中' : '保存质检结果'}</Button> : null}</div>
</Drawer>
) : null}
</>
);
}
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { Button, Field, Input, Select, Tabs } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, SimpleTable } from '../components/layout.jsx';
export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refreshApi }) {
const [activeTab, setActiveTab] = useState('customer');
const visibleRows = rechargeRows.filter((row) => row.type === activeTab);
const ownerLabel = activeTab === 'customer' ? '客户' : '供应商';
const recordTable = (
<section className="content-grid">
<Panel title={`${ownerLabel}充值记录列表`} className="wide-panel">
<SimpleTable rows={visibleRows} columns={[
{ key: 'id', label: '记录 ID', width: '118px', className: 'table-cell-compact' },
{ key: 'owner', label: ownerLabel, width: '160px', className: 'table-cell-compact' },
{ key: 'amount', label: '充值金额' },
{ key: 'beforeBalance', label: '充值前余额' },
{ key: 'afterBalance', label: '充值后余额' },
{ key: 'remark', label: '备注' },
{ key: 'operator', label: '操作人' },
{ key: 'time', label: '充值时间' },
{ key: 'status', label: '状态', status: true },
]} />
</Panel>
</section>
);
return (
<>
<PageTitle
title="充值记录"
desc="记录客户和供应商充值金额、充值前后余额、备注、操作人和充值时间。"
actions={<Button icon={<Icon type="export" />} variant="outline">导出记录</Button>}
/>
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
<Toolbar>
<Field label={`${ownerLabel}名称`}><Input placeholder={`搜索${ownerLabel}名称`} /></Field>
<Field label="状态">
<Select defaultValue="all">
<option value="all">全部状态</option>
<option>成功</option>
<option>失败</option>
</Select>
</Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<Tabs
value={activeTab}
onChange={setActiveTab}
tabs={[
{ value: 'customer', label: '客户充值', content: recordTable },
{ value: 'vendor', label: '供应商充值', content: recordTable },
]}
/>
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useMemo, useState } from 'react';
import { Alert, Badge, Button, Checkbox, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
import { permissionGroups } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteRole }) {
const [editingRole, setEditingRole] = useState(undefined);
const [roleForm, setRoleForm] = useState(emptyRoleForm);
const [permissionRole, setPermissionRole] = useState(null);
const [permissionDraft, setPermissionDraft] = useState([]);
const [deleteRoleTarget, setDeleteRoleTarget] = useState(null);
const [actionError, setActionError] = useState('');
const canManage = can('roles.manage');
const roleUserCounts = useMemo(() => userRows.reduce((counts, user) => ({ ...counts, [user.roleId]: (counts[user.roleId] || 0) + 1 }), {}), [userRows]);
const openCreateRole = () => { setEditingRole(null); setRoleForm(emptyRoleForm); };
const openEditRole = (role) => { setEditingRole(role); setRoleForm({ name: role.name, description: role.description, status: role.status }); };
const closeRoleModal = () => { setEditingRole(undefined); setRoleForm(emptyRoleForm); };
const submitRole = (event) => {
event.preventDefault();
if (!roleForm.name.trim()) return;
if (editingRole) {
setRoleRows((rows) => rows.map((role) => role.id === editingRole.id ? { ...role, ...roleForm } : role));
} else {
setRoleRows((rows) => [...rows, { id: `R${String(rows.length + 1).padStart(3, '0')}`, ...roleForm, builtIn: false, permissions: [] }]);
}
closeRoleModal();
};
const toggleRoleStatus = (role) => {
if (role.builtIn) return;
setRoleRows((rows) => rows.map((item) => item.id === role.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item));
};
const deleteRole = async (role) => {
try {
setActionError('');
if (onDeleteRole) {
await onDeleteRole(role.id);
} else {
setRoleRows((rows) => rows.filter((item) => item.id !== role.id));
}
setDeleteRoleTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
const openPermissions = (role) => { setPermissionRole(role); setPermissionDraft(role.permissions); };
const togglePermission = (permissionKey) => {
setPermissionDraft((permissions) => permissions.includes(permissionKey) ? permissions.filter((key) => key !== permissionKey) : [...permissions, permissionKey]);
};
const togglePermissionGroup = (group) => {
const groupKeys = group.permissions.map((permission) => permission.key);
const allSelected = groupKeys.every((key) => permissionDraft.includes(key));
setPermissionDraft((permissions) => allSelected ? permissions.filter((key) => !groupKeys.includes(key)) : Array.from(new Set([...permissions, ...groupKeys])));
};
const savePermissions = () => {
setRoleRows((rows) => rows.map((role) => role.id === permissionRole.id ? { ...role, permissions: permissionDraft } : role));
setPermissionRole(null);
};
return (
<>
<PageTitle title="角色与权限" desc="按岗位定义菜单与操作权限,并查看角色关联用户。" actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreateRole}>新增角色</Button> : null} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
<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={[
{ key: 'name', label: '角色名称' },
{ key: 'type', label: '类型' },
{ key: 'description', label: '角色说明' },
{ key: 'userCount', label: '用户数' },
{ key: 'permissionCount', label: '权限项' },
{ key: 'status', label: '状态', status: true },
{ key: 'actions', label: '操作', render: (row) => <div className="table-actions"><Button size="sm" variant="secondary" onClick={() => openPermissions(row)}>{canManage && !row.builtIn ? '配置权限' : '查看权限'}</Button>{canManage ? <Button size="sm" variant="outline" onClick={() => openEditRole(row)}>编辑</Button> : null}{canManage ? <Button size="sm" variant="ghost" disabled={row.builtIn} onClick={() => toggleRoleStatus(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}{canManage ? <Button size="sm" variant="danger" disabled={row.builtIn} onClick={() => setDeleteRoleTarget(row)}>删除</Button> : null}</div> },
]} />
</Panel>
{editingRole !== undefined ? (
<Modal title={editingRole ? '编辑角色' : '新增角色'} onClose={closeRoleModal} size="sm">
<form className="modal-form" onSubmit={submitRole}>
<Field label={<span>角色名称 <span className="required-star">*</span></span>}><Input value={roleForm.name} onChange={(event) => setRoleForm({ ...roleForm, name: event.target.value })} placeholder="请输入角色名称" disabled={Boolean(editingRole?.builtIn)} required /></Field>
<Field label="角色说明"><Textarea rows="4" value={roleForm.description} onChange={(event) => setRoleForm({ ...roleForm, description: event.target.value })} placeholder="说明该角色的职责范围" /></Field>
<Field label="状态"><Select value={roleForm.status} onChange={(event) => setRoleForm({ ...roleForm, status: event.target.value })} disabled={Boolean(editingRole?.builtIn)}><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
<div className="modal-actions"><Button type="button" variant="outline" onClick={closeRoleModal}>取消</Button><Button type="submit">保存角色</Button></div>
</form>
</Modal>
) : null}
{permissionRole ? (
<Drawer title={permissionRole.builtIn ? '查看角色权限' : '配置角色权限'} aside={<><Badge tone="info">{permissionRole.name}</Badge><span className="drawer-subtitle">已选择 {permissionDraft.length} 项权限</span></>} onClose={() => setPermissionRole(null)}>
{permissionRole.builtIn || !canManage ? <Alert title={permissionRole.builtIn ? '系统内置角色' : '只读权限'} tone="info">{permissionRole.builtIn ? '内置角色权限由系统维护,仅支持查看。' : '当前账号没有角色管理权限,仅支持查看。'}</Alert> : null}
<div className="permission-groups">
{permissionGroups.map((group) => {
const groupKeys = group.permissions.map((permission) => permission.key);
const selectedCount = groupKeys.filter((key) => permissionDraft.includes(key)).length;
return (
<section className="permission-group" key={group.name}>
<div className="permission-group-head"><div><strong>{group.name}</strong><span>{selectedCount}/{groupKeys.length} 已选择</span></div><Checkbox label="全选" checked={selectedCount === groupKeys.length} disabled={permissionRole.builtIn || !canManage} onChange={() => togglePermissionGroup(group)} /></div>
<div className="permission-options">{group.permissions.map((permission) => <Checkbox key={permission.key} label={permission.label} checked={permissionDraft.includes(permission.key)} disabled={permissionRole.builtIn || !canManage} onChange={() => togglePermission(permission.key)} />)}</div>
</section>
);
})}
</div>
<div className="drawer-actions"><Button variant="outline" onClick={() => setPermissionRole(null)}>关闭</Button>{permissionRole.builtIn || !canManage ? null : <Button onClick={savePermissions}>保存权限</Button>}</div>
</Drawer>
) : null}
{deleteRoleTarget ? (
<ConfirmDialog
title="删除角色确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteRoleTarget(null)}
onConfirm={() => void deleteRole(deleteRoleTarget)}
>
<p>确认删除角色{deleteRoleTarget.name}如果仍有用户使用该角色系统会拒绝删除</p>
</ConfirmDialog>
) : null}
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, SimpleTable } from '../components/layout.jsx';
import { customers, gateways, customerGatewayPolicies, vendorGatewayPolicies, routeGroups, routeRules } from '../fixtures/devFixtures.js';
export function RoutesPage() {
return (
<>
<PageTitle title="线路与路由" desc="线路组、客户网关业务分流、供应商网关映射、失败重试策略、号码前缀和生效时段。" actions={<Button icon={<Icon type="reload" />}>模拟 dr_reload</Button>} />
<section className="content-grid">
<Panel title="线路组" className="wide-panel">
<SimpleTable rows={routeGroups} columns={[
{ key: 'id', label: '线路组 ID' },
{ key: 'name', label: '线路组名称' },
{ key: 'customers', label: '适用客户' },
{ key: 'gateways', label: '网关列表' },
{ key: 'strategy', label: '路由策略' },
{ key: 'retry', label: '失败重试策略' },
{ key: 'status', label: '状态', status: true },
]} />
</Panel>
<Panel title="路由规则" className="wide-panel" aside={<Badge tone="info">dr_rules / dialplan</Badge>}>
<SimpleTable rows={routeRules} columns={[
{ key: 'id', label: '规则 ID' },
{ key: 'customer', label: '客户' },
{ key: 'prefix', label: '号码前缀' },
{ key: 'routeGroup', label: '线路组' },
{ key: 'priority', label: '优先级' },
{ key: 'window', label: '生效时间' },
{ key: 'status', label: '状态', status: true },
{ key: 'remark', label: '备注' },
]} />
</Panel>
<Panel title="客户网关业务分流" className="wide-panel">
<SimpleTable rows={customerGatewayPolicies} columns={[
{ key: 'gateway', label: '客户网关' },
{ key: 'name', label: '策略名称' },
{ key: 'callerMatch', label: '主叫匹配', render: (row) => row.callerMode === 'any' ? '不限' : `${row.callerMode === 'equals' ? '等于' : '前缀'} ${row.callerValue}` },
{ key: 'calleeMatch', label: '被叫匹配', render: (row) => row.calleeMode === 'any' ? '不限' : `${row.calleeMode === 'equals' ? '等于' : '前缀'} ${row.calleeValue}` },
{ key: 'routeGroup', label: '呼叫至线路群组' },
{ key: 'priority', label: '优先级' },
{ key: 'status', label: '状态', status: true },
]} />
</Panel>
<Panel title="供应商网关业务匹配" className="wide-panel">
<SimpleTable rows={vendorGatewayPolicies} columns={[
{ key: 'vendor', label: '供应商' },
{ key: 'gateway', label: '供应商网关' },
{ key: 'caller', label: '主叫号码/号段' },
{ key: 'calleePrefix', label: '被叫前缀' },
{ key: 'business', label: '成本业务' },
{ key: 'vendorRate', label: '供应商费率' },
{ key: 'priority', label: '优先级' },
{ key: 'status', label: '状态', status: true },
]} />
</Panel>
</section>
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { Button, Field, Input, Select, Switch } from '../components/ui.jsx';
import { PageTitle, Panel } from '../components/layout.jsx';
export function SettingsPage() {
return (
<>
<PageTitle title="系统设置" desc="SIP 域名、监听地址、MI 连接、RTPEngine、录音存储和全局计费参数。" actions={<Button>保存配置</Button>} />
<section className="settings-grid">
<Panel title="SIP 与 MI">
<Field label="默认 SIP 域名"><Input defaultValue="voice.example.net" /></Field>
<Field label="监听地址"><Input defaultValue="udp:0.0.0.0:5060" /></Field>
<Field label="MI 连接配置"><Input defaultValue="http://10.0.2.11:8080/mi" /></Field>
</Panel>
<Panel title="媒体与录音">
<Field label="RTPEngine 节点"><Input defaultValue="udp:10.0.2.21:2223" /></Field>
<Field label="录音存储"><Input defaultValue="s3://softswitch-recordings" /></Field>
<Field label="录音保留策略"><Select defaultValue="180"><option value="30">30 </option><option value="90">90 </option><option value="180">180 </option></Select></Field>
</Panel>
<Panel title="全局策略">
<Switch label="启用预付费余额控制" checked readOnly />
<Switch label="启用页面试听录音" checked readOnly />
<Switch label="启用操作审计" checked readOnly />
<Field label="默认时区"><Input defaultValue="Asia/Shanghai" /></Field>
<Field label="时间格式"><Input defaultValue="YYYY-MM-DD HH:mm:ss" /></Field>
</Panel>
<Panel title="安全策略">
<Switch label="强制 MFA" checked={false} readOnly />
<Switch label="敏感操作二次确认" checked readOnly />
<Field label="会话超时"><Input defaultValue="30 分钟" /></Field>
<Field label="导出水印"><Input defaultValue="用户 + 时间 + IP" /></Field>
</Panel>
</section>
</>
);
}
const emptyUserForm = { username: '', name: '', phone: '', email: '', roleId: 'R002', status: '启用' };
const emptyRoleForm = { name: '', description: '', status: '启用' };
+25
View File
@@ -0,0 +1,25 @@
import { Badge, Button, Progress } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, StatusBadge, SimpleTable } from '../components/layout.jsx';
import { sipAccounts, opsItems } from '../fixtures/devFixtures.js';
export function SipOpsPage() {
return (
<>
<PageTitle title="SIP 运维" desc="面向技术运维的在线注册、在线通话、SIP Trace、网关状态和 RTPEngine 状态原型。" actions={<Button icon={<Icon type="reload" />}>刷新 MI 状态</Button>} />
<section className="content-grid">
<Panel title="在线注册" className="wide-panel" aside={<Badge tone="info">usrloc</Badge>}>
<SimpleTable rows={sipAccounts} columns={[{ key: 'user', label: '账号' }, { key: 'domain', label: 'Domain' }, { key: 'register', label: '注册状态', status: true }, { key: 'contact', label: 'Contact' }, { key: 'expires', label: 'Expires' }]} />
</Panel>
<Panel title="运行检查">
{opsItems.slice(0, 3).map((item) => (
<div className="ops-row" key={item.name}><div><strong>{item.name}</strong><span>{item.target}</span></div><StatusBadge>{item.status}</StatusBadge></div>
))}
</Panel>
<Panel title="SIP Trace 摘要">
<pre className="trace-box">{'INVITE sip:13800138000@carrier.example SIP/2.0\n100 Trying\n183 Session Progress\n200 OK\nACK\nBYE\n200 OK'}</pre>
</Panel>
</section>
</>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useMemo, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { explainApiError } from '../api.js';
export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteUser }) {
const [keyword, setKeyword] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [editingUser, setEditingUser] = useState(undefined);
const [userForm, setUserForm] = useState(emptyUserForm);
const [resetTarget, setResetTarget] = useState(null);
const [deleteUserTarget, setDeleteUserTarget] = useState(null);
const [resetMessage, setResetMessage] = useState('');
const [actionError, setActionError] = useState('');
const canManage = can('users.manage');
const roleMap = useMemo(() => new Map(roleRows.map((role) => [role.id, role.name])), [roleRows]);
const visibleUsers = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
return userRows.filter((user) => {
const matchesKeyword = !normalized || [user.username, user.name, user.phone, user.email].some((value) => value.toLowerCase().includes(normalized));
return matchesKeyword && (roleFilter === 'all' || user.roleId === roleFilter) && (statusFilter === 'all' || user.status === statusFilter);
});
}, [keyword, roleFilter, statusFilter, userRows]);
const openCreateUser = () => {
setEditingUser(null);
setUserForm(emptyUserForm);
};
const openEditUser = (user) => {
setEditingUser(user);
setUserForm({ username: user.username, name: user.name, phone: user.phone, email: user.email, roleId: user.roleId, status: user.status });
};
const closeUserModal = () => {
setEditingUser(undefined);
setUserForm(emptyUserForm);
};
const submitUser = (event) => {
event.preventDefault();
if (!userForm.username.trim() || !userForm.name.trim() || !userForm.roleId) return;
if (editingUser) {
setUserRows((rows) => rows.map((user) => user.id === editingUser.id ? { ...user, ...userForm } : user));
} else {
const nextId = `U${String(1001 + userRows.length).padStart(4, '0')}`;
setUserRows((rows) => [...rows, { id: nextId, ...userForm, lastLogin: '从未登录', lastIp: '-' }]);
}
closeUserModal();
};
const toggleUserStatus = (user) => {
setUserRows((rows) => rows.map((item) => item.id === user.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item));
};
const deleteUser = async (user) => {
try {
setActionError('');
if (onDeleteUser) {
await onDeleteUser(user.id);
} else {
setUserRows((rows) => rows.filter((item) => item.id !== user.id));
}
setDeleteUserTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
const confirmResetPassword = () => {
setResetMessage(`${resetTarget.name}${resetTarget.username})的密码已重置,下次登录需修改密码。`);
setResetTarget(null);
};
return (
<>
<PageTitle title="用户管理" desc="管理运营端登录用户、所属角色、账号状态和登录安全。" actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreateUser}>新增用户</Button> : null} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
{resetMessage ? <Alert title="密码重置完成" tone="success">{resetMessage}</Alert> : null}
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
<Toolbar>
<Field label="用户信息"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户名、姓名、手机号或邮箱" /></Field>
<Field label="角色"><Select value={roleFilter} onChange={(event) => setRoleFilter(event.target.value)}><option value="all">全部角色</option>{roleRows.map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</Select></Field>
<Field label="状态"><Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="all">全部状态</option><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
<Button variant="outline" onClick={() => { setKeyword(''); setRoleFilter('all'); setStatusFilter('all'); }}>重置</Button>
</Toolbar>
<Panel title="用户列表" aside={<Badge tone="neutral"> {visibleUsers.length} 个用户</Badge>} className="wide-panel">
<SimpleTable rows={visibleUsers.map((user) => ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[
{ key: 'username', label: '用户名' },
{ key: 'name', label: '姓名' },
{ key: 'phone', label: '手机号' },
{ key: 'roleName', label: '角色' },
{ key: 'status', label: '状态', status: true },
{ key: 'lastLogin', label: '最后登录时间' },
{ key: 'lastIp', label: '最后登录 IP' },
{ key: 'actions', label: '操作', render: (row) => <div className="table-actions">{canManage ? <Button size="sm" variant="outline" onClick={() => openEditUser(row)}>编辑</Button> : null}{canManage ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => toggleUserStatus(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}{canManage ? <Button size="sm" variant="outline" onClick={() => setResetTarget(row)}>重置密码</Button> : null}{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteUserTarget(row)}>删除</Button> : '-'}</div> },
]} />
</Panel>
{editingUser !== undefined ? (
<Modal title={editingUser ? '编辑用户' : '新增用户'} onClose={closeUserModal}>
<form className="modal-form" onSubmit={submitUser}>
<div className="form-grid admin-form-grid">
<Field label={<span>用户名 <span className="required-star">*</span></span>}><Input value={userForm.username} onChange={(event) => setUserForm({ ...userForm, username: event.target.value })} placeholder="用于登录,不可重复" disabled={Boolean(editingUser)} required /></Field>
<Field label={<span>姓名 <span className="required-star">*</span></span>}><Input value={userForm.name} onChange={(event) => setUserForm({ ...userForm, name: event.target.value })} placeholder="请输入用户姓名" required /></Field>
<Field label="手机号"><Input value={userForm.phone} onChange={(event) => setUserForm({ ...userForm, phone: event.target.value })} placeholder="请输入手机号" /></Field>
<Field label="邮箱"><Input type="email" value={userForm.email} onChange={(event) => setUserForm({ ...userForm, email: event.target.value })} placeholder="请输入邮箱" /></Field>
<Field label={<span>角色 <span className="required-star">*</span></span>}><Select value={userForm.roleId} onChange={(event) => setUserForm({ ...userForm, roleId: event.target.value })} required>{roleRows.filter((role) => role.status === '启用').map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</Select></Field>
<Field label="状态"><Select value={userForm.status} onChange={(event) => setUserForm({ ...userForm, status: event.target.value })}><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
</div>
<div className="modal-actions"><Button type="button" variant="outline" onClick={closeUserModal}>取消</Button><Button type="submit">{editingUser ? '保存修改' : '保存用户'}</Button></div>
</form>
</Modal>
) : null}
{resetTarget ? (
<ConfirmDialog
title="确认重置密码"
confirmLabel="确认重置"
onCancel={() => setResetTarget(null)}
onConfirm={confirmResetPassword}
>
<p>确认重置用户{resetTarget.name}{resetTarget.username}的登录密码吗重置后该用户需要使用临时密码登录并立即修改密码</p>
</ConfirmDialog>
) : null}
{deleteUserTarget ? (
<ConfirmDialog
title="删除用户确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteUserTarget(null)}
onConfirm={() => void deleteUser(deleteUserTarget)}
>
<p>确认删除用户{deleteUserTarget.name}{deleteUserTarget.username}删除后该用户将不能登录也不再出现在用户列表中</p>
</ConfirmDialog>
) : null}
</>
);
}
+399
View File
@@ -0,0 +1,399 @@
import { useState } from 'react';
import { Button, Checkbox, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { vendors, gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows: setApiGatewayRows, vendorRows: apiVendorRows, apiLoading, apiError, refreshApi, can = () => true, onUpdateGateway, onToggleGatewayStatus, onDeleteGateway }) {
const [localGatewayRows, setLocalGatewayRows] = useState(gateways);
const gatewayRows = Array.isArray(apiGatewayRows) ? apiGatewayRows : localGatewayRows;
const setGatewayRows = setApiGatewayRows || setLocalGatewayRows;
const vendorOptions = apiVendorRows?.length ? apiVendorRows : vendors;
const [actionError, setActionError] = useState('');
const [editingGateway, setEditingGateway] = useState(null);
const [gatewayConfirm, setGatewayConfirm] = useState(null);
const provinceOptions = ['北京', '上海', '广东', '浙江', '江苏', '新疆', '西藏', '港澳台', '海外'];
const codecOptions = ['PCMA', 'PCMU', 'G729', 'G722', 'OPUS'];
const canManage = can('vendor_gateways.manage');
const splitList = (value) => (value && !['无', '-'].includes(value) ? value.split(/[,,、]/).map((item) => item.trim()).filter(Boolean) : []);
const splitTimeRanges = (value) => {
const ranges = String(value || '')
.split(/[,,、\n]/)
.map((item) => {
const [start = '', end = ''] = item.split('-').map((part) => part.trim());
return { start, end };
})
.filter((item) => item.start || item.end);
return ranges.length ? ranges : [{ start: '', end: '' }];
};
const splitRate = (value) => {
return String(value || '').trim();
};
const formatMinuteRate = (cycle, rate) => {
const billingCycle = Number(cycle);
const cycleRate = Number(rate);
if (!billingCycle || !Number.isFinite(billingCycle) || !Number.isFinite(cycleRate)) return '-';
return `¥${((cycleRate * 60) / billingCycle).toFixed(4)}/分钟`;
};
const rewritePoolRows = (value) => {
const rows = Array.isArray(value) ? value.map((item) => ({ caller: item.caller || '', weight: String(item.weight || 1) })) : [];
return rows.length ? rows : [{ caller: '', weight: '1' }];
};
const emptyGatewayForm = {
vendor: vendorOptions[0]?.name || '',
name: '',
authMode: 'IP',
ipAddress: '',
sipAccount: '',
sipPassword: '',
concurrencyLimit: '',
billingCycle: '60',
cycleRate: '',
requestRate: '',
blockedProvinces: [],
forbiddenPeriods: [{ start: '', end: '' }],
codecs: [],
landingCalleePrefix: '',
callerRewritePool: [{ caller: '', weight: '1' }],
};
const [gatewayForm, setGatewayForm] = useState(emptyGatewayForm);
const openEditGateway = (gateway) => {
setEditingGateway(gateway);
const rateValue = splitRate(gateway.requestRate);
setGatewayForm({
vendor: gateway.vendor,
name: gateway.name,
authMode: gateway.authMode,
ipAddress: gateway.ipAddress || '',
sipAccount: gateway.sipAccount || '',
sipPassword: gateway.sipPassword || '',
concurrencyLimit: String(gateway.concurrencyLimit),
billingCycle: String(gateway.billingCycle || 60),
cycleRate: String(gateway.cycleRate ?? ''),
requestRate: rateValue,
blockedProvinces: splitList(gateway.blockedProvinces),
forbiddenPeriods: splitTimeRanges(gateway.callTimeLimit),
codecs: splitList(gateway.codecs),
landingCalleePrefix: gateway.landingCalleePrefix || '',
callerRewritePool: rewritePoolRows(gateway.callerRewritePool),
});
};
const closeEditGateway = () => {
setEditingGateway(null);
setGatewayForm(emptyGatewayForm);
};
const submitGateway = async (event) => {
event.preventDefault();
if (!editingGateway || !gatewayForm.name.trim() || !gatewayForm.concurrencyLimit || !gatewayForm.billingCycle || !gatewayForm.cycleRate) return;
const billingCycle = Number(gatewayForm.billingCycle);
const cycleRate = Number(gatewayForm.cycleRate);
if (!Number.isFinite(billingCycle) || billingCycle <= 0 || billingCycle > 60 || !Number.isFinite(cycleRate) || cycleRate < 0) return;
const ipAddress = gatewayForm.ipAddress.trim();
const sipAccount = gatewayForm.sipAccount.trim();
const sipPassword = gatewayForm.sipPassword.trim();
const authValid = gatewayForm.authMode === 'IP' ? ipAddress : ipAddress && sipAccount;
if (!authValid) return;
const vendorId = vendorOptions.find((vendor) => vendor.name === gatewayForm.vendor)?.id || editingGateway.vendorId;
const callerRewritePool = gatewayForm.callerRewritePool
.map((item) => ({ caller: item.caller.trim(), weight: Number(item.weight || 1), status: 'ENABLED' }))
.filter((item) => item.caller);
const body = {
vendorId,
name: gatewayForm.name.trim(),
authMode: gatewayForm.authMode === 'SIP注册' ? 'SIP_DIGEST' : gatewayForm.authMode,
host: ipAddress,
port: editingGateway.port || 5060,
transport: editingGateway.transport || 'udp',
sipUsername: sipAccount || undefined,
cpsLimit: Number(String(gatewayForm.requestRate).match(/\d+/)?.[0] || 0),
concurrencyLimit: Number(gatewayForm.concurrencyLimit),
billingCycleSec: billingCycle,
cycleRate: String(cycleRate),
landingCalleePrefix: gatewayForm.landingCalleePrefix.trim() || null,
callerRewritePool,
forbiddenPeriods: gatewayForm.forbiddenPeriods
.filter((period) => period.start || period.end)
.map((period) => ({ weekdayMask: 127, startTime: `${period.start || '00:00'}:00`, endTime: `${period.end || '23:59'}:00` })),
codecs: gatewayForm.codecs.map((codec, index) => ({ codec, priority: index + 1 })),
prefixRules: [],
};
if (sipPassword) {
body.sipPassword = sipPassword;
}
setActionError('');
try {
if (onUpdateGateway) {
await onUpdateGateway(editingGateway.id, body);
} else {
setGatewayRows((rows) => rows.map((gateway) => (
gateway.id === editingGateway.id
? {
...gateway,
vendor: gatewayForm.vendor,
vendorId,
name: body.name,
authMode: gatewayForm.authMode,
ipAddress,
sipAccount,
sipPassword: sipPassword ? '******' : gateway.sipPassword,
concurrencyLimit: body.concurrencyLimit,
billingCycle,
cycleRate,
requestRate: gatewayForm.requestRate.trim() || '-',
blockedProvinces: gatewayForm.blockedProvinces.length ? gatewayForm.blockedProvinces.join('、') : '无',
callTimeLimit: gatewayForm.forbiddenPeriods
.filter((period) => period.start || period.end)
.map((period) => `${period.start || '00:00'}-${period.end || '23:59'}`)
.join('、') || '无',
codecs: gatewayForm.codecs.length ? gatewayForm.codecs.join(', ') : '-',
landingCalleePrefix: body.landingCalleePrefix || '',
callerRewritePool,
calleePrefixTransform: '-',
callerPrefixTransform: '-',
}
: gateway
)));
}
closeEditGateway();
} catch (error) {
setActionError(explainApiError(error));
}
};
const toggleArrayValue = (field, value) => {
setGatewayForm((form) => ({
...form,
[field]: form[field].includes(value) ? form[field].filter((item) => item !== value) : [...form[field], value],
}));
};
const updateCallerRewrite = (index, key, value) => {
setGatewayForm((form) => ({
...form,
callerRewritePool: form.callerRewritePool.map((rule, ruleIndex) => (ruleIndex === index ? { ...rule, [key]: value } : rule)),
}));
};
const addCallerRewrite = () => {
setGatewayForm((form) => ({ ...form, callerRewritePool: [...form.callerRewritePool, { caller: '', weight: '1' }] }));
};
const removeCallerRewrite = (index) => {
setGatewayForm((form) => ({
...form,
callerRewritePool: form.callerRewritePool.length > 1 ? form.callerRewritePool.filter((_, ruleIndex) => ruleIndex !== index) : [{ caller: '', weight: '1' }],
}));
};
const updateForbiddenPeriod = (index, key, value) => {
setGatewayForm((form) => ({
...form,
forbiddenPeriods: form.forbiddenPeriods.map((period, periodIndex) => (
periodIndex === index ? { ...period, [key]: value } : period
)),
}));
};
const addForbiddenPeriod = () => {
setGatewayForm((form) => ({ ...form, forbiddenPeriods: [...form.forbiddenPeriods, { start: '', end: '' }] }));
};
const removeForbiddenPeriod = (index) => {
setGatewayForm((form) => ({
...form,
forbiddenPeriods: form.forbiddenPeriods.length > 1 ? form.forbiddenPeriods.filter((_, periodIndex) => periodIndex !== index) : [{ start: '', end: '' }],
}));
};
const toggleVendorGatewayStatus = async (gateway) => {
setActionError('');
try {
if (onToggleGatewayStatus) {
await onToggleGatewayStatus(gateway);
return;
}
setGatewayRows((rows) => rows.map((item) => (
item.id === gateway.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item
)));
} catch (error) {
setActionError(explainApiError(error));
}
};
const deleteVendorGateway = async (gateway) => {
setActionError('');
try {
if (onDeleteGateway) {
await onDeleteGateway(gateway.id);
return;
}
setGatewayRows((rows) => rows.filter((item) => item.id !== gateway.id));
} catch (error) {
setActionError(explainApiError(error));
}
};
return (
<>
<PageTitle title="落地网关管理" desc="管理供应商落地网关认证、并发、价格和号码限制策略。" actions={canManage ? <Button icon={<Icon type="plus" />}>新增落地网关</Button> : null} />
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
<Toolbar>
<Field label="供应商"><Select defaultValue="all"><option value="all">全部供应商</option>{vendorOptions.map((vendor) => <option key={vendor.id}>{vendor.name}</option>)}</Select></Field>
<Field label="落地网关名称"><Input placeholder="搜索网关名称" /></Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<section className="content-grid">
<Panel title="落地网关列表" className="wide-panel">
<SimpleTable rows={gatewayRows} columns={[
{ key: 'vendor', label: '供应商名称', width: '156px', className: 'table-cell-compact' },
{ key: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' },
{ key: 'authMode', label: '认证方式' },
{ key: 'authTarget', label: 'IP/账号', render: (row) => (row.authMode === 'IP' ? row.ipAddress : row.sipAccount) },
{ key: 'concurrencyLimit', label: '并发上限' },
{ key: 'minuteRate', label: '价格', render: (row) => formatMinuteRate(row.billingCycle, row.cycleRate) },
{ key: 'landingCalleePrefix', label: '落地被叫前缀', render: (row) => row.landingCalleePrefix || '-' },
{ key: 'callerRewriteCount', label: '指定主叫数', render: (row) => (row.callerRewritePool || []).length },
{ key: 'status', label: '状态', status: true },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditGateway(row)}>编辑</Button> : null}
{canManage ? (
<Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setGatewayConfirm({ type: 'toggle', row })}>
{row.status === '启用' ? '禁用' : '启用'}
</Button>
) : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setGatewayConfirm({ type: 'delete', row })}>删除</Button> : '-'}
</div>
),
},
]} />
</Panel>
</section>
{editingGateway ? (
<Modal title="编辑落地网关" onClose={closeEditGateway} size="lg">
<form className="modal-form" onSubmit={submitGateway}>
<div className="match-grid">
<Field label={<span>供应商名称 <span className="required-star">*</span></span>}>
<Select value={gatewayForm.vendor} onChange={(event) => setGatewayForm({ ...gatewayForm, vendor: event.target.value })} required>
{vendorOptions.map((vendor) => <option key={vendor.id}>{vendor.name}</option>)}
</Select>
</Field>
<Field label={<span>落地网关名称 <span className="required-star">*</span></span>}>
<Input value={gatewayForm.name} onChange={(event) => setGatewayForm({ ...gatewayForm, name: event.target.value })} placeholder="请输入落地网关名称" required />
</Field>
<Field label={<span>认证方式 <span className="required-star">*</span></span>}>
<Select value={gatewayForm.authMode} onChange={(event) => setGatewayForm({ ...gatewayForm, authMode: event.target.value, ipAddress: '', sipAccount: '', sipPassword: '' })} required>
<option>IP</option>
<option>SIP注册</option>
</Select>
</Field>
<Field label={<span>落地主机/IP <span className="required-star">*</span></span>}>
<Input value={gatewayForm.ipAddress} onChange={(event) => setGatewayForm({ ...gatewayForm, ipAddress: event.target.value })} placeholder="例如 203.0.113.18 或 sip.carrier.local" required />
</Field>
{gatewayForm.authMode !== 'IP' ? (
<>
<Field label={<span>SIP账号 <span className="required-star">*</span></span>}>
<Input value={gatewayForm.sipAccount} onChange={(event) => setGatewayForm({ ...gatewayForm, sipAccount: event.target.value })} placeholder="请输入 SIP 账号" required />
</Field>
<Field label="SIP密码(留空不修改)">
<Input type="password" value={gatewayForm.sipPassword} onChange={(event) => setGatewayForm({ ...gatewayForm, sipPassword: event.target.value })} placeholder="至少 12 位" />
</Field>
</>
) : null}
<Field label={<span>并发上限 <span className="required-star">*</span></span>}>
<Input type="number" min="1" value={gatewayForm.concurrencyLimit} onChange={(event) => setGatewayForm({ ...gatewayForm, concurrencyLimit: event.target.value })} placeholder="例如 800" required />
</Field>
<div className="rate-config-card">
<div className="rate-config-head">
<strong>费率配置</strong>
<span>{formatMinuteRate(gatewayForm.billingCycle, gatewayForm.cycleRate)}</span>
</div>
<div className="rate-config-fields">
<Field label={<span>计费周期 <span className="required-star">*</span></span>}>
<Input type="number" min="1" max="60" value={gatewayForm.billingCycle} onChange={(event) => setGatewayForm({ ...gatewayForm, billingCycle: event.target.value })} placeholder="最大 60 秒" required />
</Field>
<Field label={<span>周期费率 <span className="required-star">*</span></span>}>
<Input type="number" min="0" step="0.0001" value={gatewayForm.cycleRate} onChange={(event) => setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="例如 0.02" required />
</Field>
</div>
</div>
<div className="config-card">
<strong>请求速率</strong>
<Input value={gatewayForm.requestRate} onChange={(event) => setGatewayForm({ ...gatewayForm, requestRate: event.target.value })} placeholder="例如 120 CPS" />
</div>
<div className="config-card">
<strong>屏蔽省份</strong>
<div className="option-grid">
{provinceOptions.map((province) => (
<Checkbox key={province} label={province} checked={gatewayForm.blockedProvinces.includes(province)} onChange={() => toggleArrayValue('blockedProvinces', province)} />
))}
</div>
</div>
<div className="config-card">
<div className="config-card-head">
<strong>禁呼时段</strong>
<Button type="button" size="sm" variant="outline" onClick={addForbiddenPeriod}>添加时段</Button>
</div>
<div className="rule-list">
{gatewayForm.forbiddenPeriods.map((period, index) => (
<div className="time-range" key={`forbidden-${index}`}>
<Input type="time" value={period.start} onChange={(event) => updateForbiddenPeriod(index, 'start', event.target.value)} />
<span></span>
<Input type="time" value={period.end} onChange={(event) => updateForbiddenPeriod(index, 'end', event.target.value)} />
<Button type="button" size="sm" variant="danger" onClick={() => removeForbiddenPeriod(index)}>删除</Button>
</div>
))}
</div>
</div>
<div className="config-card">
<strong>语音编码限制</strong>
<div className="option-grid">
{codecOptions.map((codec) => (
<Checkbox key={codec} label={codec} checked={gatewayForm.codecs.includes(codec)} onChange={() => toggleArrayValue('codecs', codec)} />
))}
</div>
</div>
<div className="config-card config-card-wide">
<strong>落地要求被叫前缀</strong>
<Input value={gatewayForm.landingCalleePrefix} onChange={(event) => setGatewayForm({ ...gatewayForm, landingCalleePrefix: event.target.value })} placeholder="可为空,例如 86 或 ABC" />
</div>
<div className="config-card config-card-wide">
<div className="config-card-head">
<strong>落地要求指定主叫</strong>
<Button type="button" size="sm" variant="outline" onClick={addCallerRewrite}>添加号码</Button>
</div>
<div className="rule-list">
{gatewayForm.callerRewritePool.map((rule, index) => (
<div className="prefix-rule" key={`caller-${index}`}>
<Input value={rule.caller} onChange={(event) => updateCallerRewrite(index, 'caller', event.target.value)} placeholder="指定主叫,如 02160010001" />
<span>权重</span>
<Input type="number" min="1" value={rule.weight} onChange={(event) => updateCallerRewrite(index, 'weight', event.target.value)} />
<Button type="button" size="sm" variant="danger" onClick={() => removeCallerRewrite(index)}>删除</Button>
</div>
))}
</div>
</div>
</div>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeEditGateway}>取消</Button>
<Button type="submit">保存修改</Button>
</div>
</form>
</Modal>
) : null}
{gatewayConfirm ? (
<ConfirmDialog
title={gatewayConfirm.type === 'delete' ? '删除落地网关确认' : `${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}落地网关确认`}
confirmLabel={gatewayConfirm.type === 'delete' ? '确认删除' : `确认${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}`}
confirmVariant={gatewayConfirm.type === 'delete' ? 'danger' : 'primary'}
onCancel={() => setGatewayConfirm(null)}
onConfirm={() => {
const { type, row } = gatewayConfirm;
setGatewayConfirm(null);
return type === 'delete' ? void deleteVendorGateway(row) : void toggleVendorGatewayStatus(row);
}}
>
{gatewayConfirm.type === 'delete' ? (
<p>确认删除落地网关{gatewayConfirm.row.name}删除后该网关将不再参与落地</p>
) : (
<p>确认{gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}落地网关{gatewayConfirm.row.name}</p>
)}
</ConfirmDialog>
) : null}
</>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
import { formatDate, zhStatus } from '../utils/formatters.js';
import { gateways, vendorLineGroups } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineGroupRows: setApiLineGroupRows, gatewayRows: apiGatewayRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteLineGroup }) {
const [localLineGroupRows, setLocalLineGroupRows] = useState(vendorLineGroups);
const lineGroupRows = Array.isArray(apiLineGroupRows) ? apiLineGroupRows : localLineGroupRows;
const setLineGroupRows = setApiLineGroupRows || setLocalLineGroupRows;
const availableGateways = apiGatewayRows?.length ? apiGatewayRows : gateways;
const [actionError, setActionError] = useState('');
const [editingLineGroup, setEditingLineGroup] = useState(null);
const [deleteLineGroupTarget, setDeleteLineGroupTarget] = useState(null);
const [lineGroupForm, setLineGroupForm] = useState({ name: '', gatewayIds: [] });
const [showAddGatewayModal, setShowAddGatewayModal] = useState(false);
const [addGatewayForm, setAddGatewayForm] = useState({ gatewayId: '' });
const canManage = can('line_groups.manage');
const findGateway = (gatewayId) => availableGateways.find((gateway) => gateway.id === gatewayId);
const getLineGroupConcurrency = (group) => group.gatewayIds.reduce((total, gatewayId) => total + (findGateway(gatewayId)?.concurrencyLimit || 0), 0);
const openEditLineGroup = (group) => {
setEditingLineGroup(group);
setLineGroupForm({ name: group.name, gatewayIds: [...group.gatewayIds] });
setShowAddGatewayModal(false);
setAddGatewayForm({ gatewayId: '' });
};
const closeEditLineGroup = () => {
setEditingLineGroup(null);
setLineGroupForm({ name: '', gatewayIds: [] });
setShowAddGatewayModal(false);
setAddGatewayForm({ gatewayId: '' });
};
const addGatewayToLineGroup = () => {
if (!addGatewayForm.gatewayId || lineGroupForm.gatewayIds.includes(addGatewayForm.gatewayId)) return;
setLineGroupForm((form) => ({ ...form, gatewayIds: [...form.gatewayIds, addGatewayForm.gatewayId] }));
setAddGatewayForm({ gatewayId: '' });
setShowAddGatewayModal(false);
};
const removeGatewayFromLineGroup = (gatewayId) => {
setLineGroupForm((form) => ({ ...form, gatewayIds: form.gatewayIds.filter((id) => id !== gatewayId) }));
};
const moveLineGroupGateway = (gatewayId, direction) => {
setLineGroupForm((form) => {
const ids = [...form.gatewayIds];
const currentIndex = ids.indexOf(gatewayId);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= ids.length) return form;
[ids[currentIndex], ids[nextIndex]] = [ids[nextIndex], ids[currentIndex]];
return { ...form, gatewayIds: ids };
});
};
const submitLineGroup = (event) => {
event.preventDefault();
if (!editingLineGroup || !lineGroupForm.name.trim()) return;
setLineGroupRows((rows) => rows.map((group) => (
group.id === editingLineGroup.id ? { ...group, name: lineGroupForm.name.trim(), gatewayIds: lineGroupForm.gatewayIds } : group
)));
closeEditLineGroup();
};
const deleteLineGroup = async (group) => {
if ((group.customerGatewayCount ?? 0) > 0) {
setActionError(`线路组「${group.name}」仍被 ${group.customerGatewayCount} 个客户网关使用,不能删除。`);
return;
}
setActionError('');
try {
if (onDeleteLineGroup) {
await onDeleteLineGroup(group.id);
setDeleteLineGroupTarget(null);
return;
}
setLineGroupRows((rows) => rows.filter((item) => item.id !== group.id));
setDeleteLineGroupTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
return (
<>
<PageTitle title="落地线路组" desc="配置落地线路组、组内线路优先级和汇总并发上限。" actions={canManage ? <Button icon={<Icon type="plus" />}>新增线路组</Button> : null} />
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
<Toolbar>
<Field label="线路组名称"><Input placeholder="搜索线路组名称" /></Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<section className="content-grid">
<Panel title="落地线路组列表" className="wide-panel">
<SimpleTable rows={lineGroupRows} columns={[
{ key: 'name', label: '名称' },
{ key: 'lineCount', label: '线路数量', render: (row) => row.gatewayIds.length },
{ key: 'customerGatewayCount', label: '使用客户网关数' },
{ key: 'concurrencyLimit', label: '并发上限', render: (row) => getLineGroupConcurrency(row) },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditLineGroup(row)}>编辑</Button> : null}
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteLineGroupTarget(row)}>删除</Button> : '-'}
</div>
),
},
]} />
</Panel>
</section>
{editingLineGroup ? (
<Drawer title="编辑落地线路组" aside={<Badge tone="info">{editingLineGroup.id}</Badge>} onClose={closeEditLineGroup}>
<form className="modal-form" onSubmit={submitLineGroup}>
<Field label={<span>线路组名称 <span className="required-star">*</span></span>}>
<Input value={lineGroupForm.name} onChange={(event) => setLineGroupForm({ ...lineGroupForm, name: event.target.value })} placeholder="请输入线路组名称" required />
</Field>
<div className="drawer-toolbar">
<div>
<strong>{lineGroupForm.gatewayIds.length} 条线路</strong>
<span>按优先级从小到大尝试落地网关</span>
</div>
<Button type="button" icon={<Icon type="plus" />} onClick={() => setShowAddGatewayModal(true)}>添加网关</Button>
</div>
<SimpleTable rows={lineGroupForm.gatewayIds.map((gatewayId, index) => {
const gateway = findGateway(gatewayId);
return {
id: gatewayId,
priority: index + 1,
vendor: gateway?.vendor || '-',
name: gateway?.name || gatewayId,
concurrencyLimit: gateway?.concurrencyLimit || 0,
};
})} columns={[
{ key: 'priority', label: '优先级' },
{ key: 'vendor', label: '供应商' },
{ key: 'name', label: '落地网关' },
{ key: 'concurrencyLimit', label: '并发上限' },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
<Button type="button" size="sm" variant="outline" onClick={() => moveLineGroupGateway(row.id, -1)}>上移</Button>
<Button type="button" size="sm" variant="outline" onClick={() => moveLineGroupGateway(row.id, 1)}>下移</Button>
<Button type="button" size="sm" variant="danger" onClick={() => removeGatewayFromLineGroup(row.id)}>删除</Button>
</div>
),
},
]} />
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeEditLineGroup}>取消</Button>
<Button type="submit">保存线路组</Button>
</div>
</form>
</Drawer>
) : null}
{showAddGatewayModal ? (
<Modal title="添加落地网关" onClose={() => setShowAddGatewayModal(false)} size="sm">
<form className="modal-form" onSubmit={(event) => { event.preventDefault(); addGatewayToLineGroup(); }}>
<Field label={<span>落地网关 <span className="required-star">*</span></span>}>
<Select value={addGatewayForm.gatewayId} onChange={(event) => setAddGatewayForm({ gatewayId: event.target.value })} required>
<option value="">请选择落地网关</option>
{availableGateways
.filter((gateway) => !lineGroupForm.gatewayIds.includes(gateway.id))
.map((gateway) => <option key={gateway.id} value={gateway.id}>{gateway.vendor} / {gateway.name}</option>)}
</Select>
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={() => setShowAddGatewayModal(false)}>取消</Button>
<Button type="submit">确认添加</Button>
</div>
</form>
</Modal>
) : null}
{deleteLineGroupTarget ? (
<ConfirmDialog
title="删除落地线路组确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteLineGroupTarget(null)}
onConfirm={() => void deleteLineGroup(deleteLineGroupTarget)}
>
<p>确认删除落地线路组{deleteLineGroupTarget.name}</p>
</ConfirmDialog>
) : null}
</>
);
}
const emptyBusinessPrefixForm = { prefix: '', name: '', description: '', priority: 100, status: 'ENABLED' };
function normalizeBusinessPrefix(item) {
return {
id: item.id,
prefix: item.prefix,
name: item.name,
description: item.description || '-',
priority: item.priority ?? 100,
status: zhStatus(item.status),
gatewayCount: item.gatewayCount ?? 0,
createdAt: formatDate(item.createdAt),
};
}
+196
View File
@@ -0,0 +1,196 @@
import { useState } from 'react';
import { Button, Field, Input, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
import { gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateVendor, onUpdateVendor, onDeleteVendor, onRechargeVendor }) {
const [showCreateVendor, setShowCreateVendor] = useState(false);
const [editingVendor, setEditingVendor] = useState(null);
const [rechargeVendor, setRechargeVendor] = useState(null);
const [deleteVendorTarget, setDeleteVendorTarget] = useState(null);
const [newVendor, setNewVendor] = useState({ name: '' });
const [editVendorForm, setEditVendorForm] = useState({ name: '' });
const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' });
const [actionError, setActionError] = useState('');
const canManageVendors = can('vendors.manage');
const canManageRecharges = can('recharges.manage');
const parseMoney = (value) => Number(String(value).replace(/[^\d.-]/g, '')) || 0;
const formatMoney = (value) => `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const openEditVendor = (vendor) => {
setEditingVendor(vendor);
setEditVendorForm({ name: vendor.name || '' });
};
const closeEditVendor = () => {
setEditingVendor(null);
setEditVendorForm({ name: '' });
};
const openRechargeVendor = (vendor) => {
setRechargeVendor(vendor);
setRechargeForm({ amount: '', remark: '' });
};
const closeRechargeVendor = () => {
setRechargeVendor(null);
setRechargeForm({ amount: '', remark: '' });
};
const submitVendor = async (event) => {
event.preventDefault();
if (!newVendor.name.trim()) return;
setActionError('');
try {
if (onCreateVendor) {
await onCreateVendor({ name: newVendor.name.trim(), creditLimit: '0.000000' });
} else {
const nextIndex = vendorRows.length + 1;
setVendorRows((rows) => [...rows, { id: `V${String(2000 + nextIndex)}`, name: newVendor.name.trim(), balance: '¥0.00', credit: '¥0', gateways: 0, status: '启用', cycle: '待配置', ratePlan: '待配置', contact: '-', createdAt: '2026-06-19' }]);
}
setNewVendor({ name: '' });
setShowCreateVendor(false);
} catch (error) {
setActionError(explainApiError(error));
}
};
const submitEditVendor = async (event) => {
event.preventDefault();
if (!editVendorForm.name.trim() || !editingVendor) return;
setActionError('');
try {
if (onUpdateVendor) {
await onUpdateVendor(editingVendor.id, { name: editVendorForm.name.trim() });
} else {
setVendorRows((rows) => rows.map((vendor) => (
vendor.id === editingVendor.id ? { ...vendor, name: editVendorForm.name.trim() } : vendor
)));
}
closeEditVendor();
} catch (error) {
setActionError(explainApiError(error));
}
};
const submitRecharge = async (event) => {
event.preventDefault();
const amount = Number(rechargeForm.amount);
if (!rechargeVendor || !Number.isFinite(amount) || amount <= 0) return;
setActionError('');
try {
if (onRechargeVendor) {
await onRechargeVendor(rechargeVendor.id, { amount: amount.toFixed(2), remark: rechargeForm.remark.trim() || undefined });
} else {
const beforeBalance = parseMoney(rechargeVendor.balance);
const afterBalance = beforeBalance + amount;
setVendorRows((rows) => rows.map((vendor) => (
vendor.id === rechargeVendor.id ? { ...vendor, balance: formatMoney(afterBalance) } : vendor
)));
addRechargeRecord({ id: `RCG-V-${Date.now()}`, type: 'vendor', owner: rechargeVendor.name, amount: formatMoney(amount), beforeBalance: formatMoney(beforeBalance), afterBalance: formatMoney(afterBalance), remark: rechargeForm.remark.trim() || '-', operator: '运营管理员', time: new Date().toLocaleString('zh-CN', { hour12: false }), status: '成功' });
}
closeRechargeVendor();
} catch (error) {
setActionError(explainApiError(error));
}
};
const deleteVendor = async (vendor) => {
setActionError('');
try {
if (onDeleteVendor) {
await onDeleteVendor(vendor.id);
} else {
if ((vendor.gateways ?? 0) > 0) {
throw new Error('该供应商仍有关联落地网关,不能删除。');
}
setVendorRows((rows) => rows.filter((item) => item.id !== vendor.id));
}
setDeleteVendorTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
return (
<>
<PageTitle title="供应商管理" desc="管理供应商账户、余额、授信和充值记录。" actions={canManageVendors ? <Button icon={<Icon type="plus" />} onClick={() => setShowCreateVendor(true)}>新增供应商</Button> : null} />
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
<Toolbar>
<Field label="供应商名称"><Input placeholder="搜索供应商名称" /></Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<section className="master-detail">
<Panel title="供应商列表" className="wide-panel">
<SimpleTable rows={vendorRows} columns={[
{ key: 'id', label: '供应商 ID', width: '112px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
{ key: 'balance', label: '余额' },
{ key: 'credit', label: '授信额度' },
{ key: 'gateways', label: '落地网关数' },
{ key: 'status', label: '状态', status: true },
{
key: 'actions',
label: '操作',
render: (row) => (
<div className="table-actions">
{canManageVendors ? <Button size="sm" variant="outline" onClick={() => openEditVendor(row)}>编辑</Button> : null}
{canManageRecharges ? <Button size="sm" variant="secondary" onClick={() => openRechargeVendor(row)}>充值</Button> : null}
{canManageVendors ? <Button size="sm" variant="danger" onClick={() => setDeleteVendorTarget(row)}>删除</Button> : null}
{!canManageVendors && !canManageRecharges ? '-' : null}
</div>
),
},
]} />
</Panel>
</section>
{showCreateVendor ? (
<Modal title="新增供应商" onClose={() => setShowCreateVendor(false)} size="sm">
<form className="modal-form" onSubmit={submitVendor}>
<Field label={<span>供应商名称 <span className="required-star">*</span></span>}>
<Input value={newVendor.name} onChange={(event) => setNewVendor({ ...newVendor, name: event.target.value })} placeholder="请输入供应商名称" required />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={() => setShowCreateVendor(false)}>取消</Button>
<Button type="submit">保存供应商</Button>
</div>
</form>
</Modal>
) : null}
{editingVendor ? (
<Modal title="编辑供应商" onClose={closeEditVendor} size="sm">
<form className="modal-form" onSubmit={submitEditVendor}>
<Field label={<span>供应商名称 <span className="required-star">*</span></span>}>
<Input value={editVendorForm.name} onChange={(event) => setEditVendorForm({ ...editVendorForm, name: event.target.value })} placeholder="请输入供应商名称" required />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeEditVendor}>取消</Button>
<Button type="submit">保存修改</Button>
</div>
</form>
</Modal>
) : null}
{rechargeVendor ? (
<Modal title={`${rechargeVendor.name} 充值`} onClose={closeRechargeVendor} size="sm">
<form className="modal-form" onSubmit={submitRecharge}>
<KeyValue label="当前余额" value={rechargeVendor.balance} />
<Field label={<span>充值金额 <span className="required-star">*</span></span>}>
<Input type="number" min="0.01" step="0.01" value={rechargeForm.amount} onChange={(event) => setRechargeForm({ ...rechargeForm, amount: event.target.value })} placeholder="请输入充值金额" required />
</Field>
<Field label="备注">
<Textarea rows="4" value={rechargeForm.remark} onChange={(event) => setRechargeForm({ ...rechargeForm, remark: event.target.value })} placeholder="请输入备注" />
</Field>
<div className="modal-actions">
<Button type="button" variant="outline" onClick={closeRechargeVendor}>取消</Button>
<Button type="submit">确认充值</Button>
</div>
</form>
</Modal>
) : null}
{deleteVendorTarget ? (
<ConfirmDialog
title="删除供应商确认"
confirmLabel="确认删除"
confirmVariant="danger"
onCancel={() => setDeleteVendorTarget(null)}
onConfirm={() => void deleteVendor(deleteVendorTarget)}
>
<p>确认删除供应商{deleteVendorTarget.name}删除后该供应商将不再出现在供应商列表中</p>
</ConfirmDialog>
) : null}
</>
);
}
+38
View File
@@ -0,0 +1,38 @@
export const PAGE_PERMISSIONS = {
dashboard: ['dashboard.view'],
activeCalls: ['active_calls.view'],
customers: ['customers.view'],
customerGateways: ['customer_gateways.view'],
businessPrefixes: ['customer_gateways.view'],
rechargeRecords: ['recharges.view'],
vendors: ['vendors.view'],
vendorGateways: ['vendor_gateways.view'],
vendorLineGroups: ['line_groups.view'],
numberLibrary: ['number_library.view'],
cdr: ['cdr.view'],
quality: ['quality.view'],
users: ['users.view'],
roles: ['roles.view'],
operationLogs: ['audit.view'],
};
export function permissionSet(user) {
return new Set(Array.isArray(user?.permissions) ? user.permissions : []);
}
export function can(userOrPermissions, permission) {
if (!permission) return true;
const permissions = userOrPermissions instanceof Set ? userOrPermissions : permissionSet(userOrPermissions);
return permissions.has(permission);
}
export function canAll(userOrPermissions, permissions = []) {
const granted = userOrPermissions instanceof Set ? userOrPermissions : permissionSet(userOrPermissions);
return permissions.every((permission) => granted.has(permission));
}
export function canAny(userOrPermissions, permissions = []) {
if (!permissions.length) return true;
const granted = userOrPermissions instanceof Set ? userOrPermissions : permissionSet(userOrPermissions);
return permissions.some((permission) => granted.has(permission));
}
+5
View File
@@ -1144,6 +1144,11 @@ button:disabled {
white-space: nowrap;
}
.recording-player {
width: 100%;
min-height: 40px;
}
.transcript {
display: grid;
gap: 8px;
+295
View File
@@ -0,0 +1,295 @@
const selectedBlue = '#2563EB';
function formatCurrency(value, digits = 2) {
const numeric = Number(value || 0);
return `¥${numeric.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })}`;
}
function formatDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleDateString('zh-CN');
}
function formatDateTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('zh-CN', { hour12: false });
}
function formatDurationText(seconds) {
const value = Math.max(0, Number(seconds) || 0);
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
const remainSeconds = value % 60;
return hours > 0
? `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainSeconds).padStart(2, '0')}`
: `${String(minutes).padStart(2, '0')}:${String(remainSeconds).padStart(2, '0')}`;
}
function zhStatus(status) {
return status === 'ENABLED' ? '启用' : status === 'DISABLED' ? '停用' : status === 'SUCCEEDED' ? '成功' : status || '-';
}
function carrierLabel(value) {
const labels = {
MOBILE: '移动',
UNICOM: '联通',
TELECOM: '电信',
BROADCAST: '广电',
MVNO: '虚拟运营商',
UNKNOWN: '未知',
};
return labels[value] || value || '-';
}
function enStatus(status) {
return status === '启用' ? 'ENABLED' : status === '停用' || status === '禁用' ? 'DISABLED' : status;
}
function billingModeLabel(value) {
return value === 'POSTPAID' ? '后付费' : value === 'PREPAID' ? '预付费' : '-';
}
function normalizeCustomer(item) {
return {
id: item.id,
name: item.name,
contact: item.contactName || '-',
phone: item.phone || '-',
email: item.email || '-',
domain: item.domain || '-',
auth: '真实 API',
status: zhStatus(item.status),
balance: formatCurrency(item.balance),
credit: formatCurrency(item.creditLimit),
billing: billingModeLabel(item.billingMode),
routeGroup: '-',
gateways: item.gatewayCount ?? 0,
createdAt: formatDate(item.createdAt),
};
}
function normalizeVendor(item) {
return {
id: item.id,
name: item.name,
balance: formatCurrency(item.balance),
credit: formatCurrency(item.creditLimit),
gateways: item.gatewayCount ?? 0,
status: zhStatus(item.status),
cycle: item.settlement || '-',
ratePlan: '-',
contact: item.contactName || '-',
createdAt: formatDate(item.createdAt),
};
}
function authModeLabel(value) {
if (value === 'SIP_DIGEST') return 'SIP注册';
if (value === 'MIXED') return '混合认证';
return value || 'IP';
}
function normalizeCustomerGateway(item) {
return {
id: item.id,
customerId: item.customerId,
customer: item.customerName || '-',
name: item.name,
authMode: authModeLabel(item.authMode),
ipAddress: item.sourceIp || '',
sourceIps: item.sourceIps || (item.sourceIp ? [item.sourceIp] : []),
sipAccount: item.sipUsername || '',
sipDomain: item.sipDomain || '',
sipPassword: item.hasSipCredential ? '******' : '',
lineGroupId: item.lineGroupId || '',
lineGroupName: item.lineGroupName || '-',
billingCycleSec: item.billingCycleSec ?? 60,
cycleRate: Number(item.cycleRate || 0),
callerMatchMode: item.callerMatchMode || 'ANY',
callerPrefixes: item.callerPrefixes || [],
calleeMatchMode: item.calleeMatchMode || 'ANY',
businessPrefixes: item.businessPrefixes || [],
routePolicyCount: item.policyCount ?? 0,
status: zhStatus(item.status),
createdAt: formatDate(item.createdAt),
};
}
function normalizeVendorGateway(item) {
return {
id: item.id,
vendorId: item.vendorId,
vendor: item.vendorName || '-',
name: item.name,
authMode: authModeLabel(item.authMode),
ipAddress: item.host || '',
sipAccount: item.sipUsername || '',
sipPassword: item.hasSipCredential ? '******' : '',
concurrencyLimit: item.concurrencyLimit ?? 0,
billingCycle: item.billingCycleSec ?? 60,
cycleRate: Number(item.cycleRate || 0),
requestRate: `${item.cpsLimit ?? 0} CPS`,
blockedProvinces: '-',
callTimeLimit: '-',
codecs: (item.codecs || []).map((codec) => codec.codec).join(', ') || '-',
landingCalleePrefix: item.landingCalleePrefix || '',
callerRewritePool: item.callerRewritePool || [],
calleePrefixTransform: (item.prefixRules || []).filter((rule) => rule.direction === 'CALLEE').map((rule) => `${rule.matchPrefix} -> ${rule.replacePrefix || rule.matchPrefix}`).join('\n') || '-',
callerPrefixTransform: (item.prefixRules || []).filter((rule) => rule.direction === 'CALLER').map((rule) => `${rule.matchPrefix} -> ${rule.replacePrefix || rule.matchPrefix}`).join('\n') || '-',
status: zhStatus(item.status),
createdAt: formatDate(item.createdAt),
};
}
function normalizeLandingLineGroup(item) {
return {
id: item.id,
name: item.name,
status: zhStatus(item.status),
customerGatewayCount: item.customerGatewayCount ?? 0,
policyCount: item.policyCount ?? 0,
gatewayIds: (item.items || []).map((line) => line.vendorGatewayId),
items: item.items || [],
createdAt: formatDate(item.createdAt),
};
}
function normalizeRecharge(item) {
return {
id: item.id,
type: item.accountType === 'VENDOR' ? 'vendor' : 'customer',
owner: item.accountName,
amount: formatCurrency(item.amount),
beforeBalance: formatCurrency(item.beforeBalance),
afterBalance: formatCurrency(item.afterBalance),
remark: item.remark || '-',
operator: item.createdBy || '系统',
time: formatDateTime(item.occurredAt),
status: zhStatus(item.status),
};
}
function normalizeUser(item) {
return {
id: item.id,
username: item.username,
name: item.displayName,
phone: item.phone || '-',
email: item.email || '-',
roleId: item.roleIds?.[0] || '',
status: zhStatus(item.status),
lastLogin: formatDateTime(item.lastLoginAt),
lastIp: '-',
};
}
function normalizeRole(item) {
return {
id: item.id,
name: item.name,
description: item.description || '-',
status: zhStatus(item.status),
builtIn: Boolean(item.builtIn),
permissions: item.permissionIds || [],
userCount: item.userCount ?? 0,
};
}
function normalizeAuditLog(item) {
return {
id: item.id,
time: formatDateTime(item.createdAt),
user: item.username || '系统',
username: item.username || '-',
module: item.module,
action: item.action,
object: item.objectId ? `${item.objectType}:${item.objectId}` : item.objectType,
result: item.result === 'SUCCESS' ? '成功' : '失败',
ip: item.ip || '-',
summary: item.errorCode || `${item.module}.${item.action}`,
userAgent: item.userAgent || '-',
};
}
function reviewResultLabel(value) {
return value === 'PASS' ? '通过' : value === 'ISSUE' ? '有问题' : value === 'ESCALATED' ? '升级处理' : '-';
}
function reviewResultValue(label) {
if (label === '有问题') return 'ISSUE';
if (label === '升级处理') return 'ESCALATED';
return 'PASS';
}
function normalizeQualityRule(item) {
return {
id: item.id,
name: item.name,
customerId: item.customerId || '',
customer: item.customerName || '全部客户',
lineGroupId: item.lineGroupId || '',
route: item.lineGroupName || '全部线路',
ratio: Number(item.ratio || 0),
status: zhStatus(item.status),
start: item.effectiveAt ? new Date(item.effectiveAt).toISOString().slice(0, 10) : '',
expiresAt: item.expiresAt ? new Date(item.expiresAt).toISOString().slice(0, 10) : '',
};
}
function normalizeRecording(item) {
const latestReview = item.latestReview || null;
const reviewStatus = item.reviewStatus === 'REVIEWED' || latestReview ? '已完成' : item.sampling?.selected ? '待质检' : '未抽中';
return {
...item,
id: item.id,
callId: item.callId || item.rawCdrId || item.id,
customer: item.customerName || item.customerId || '-',
caller: item.caller || '-',
callee: item.callee || '-',
business: item.lineGroupName || '-',
time: formatDateTime(item.startedAt || item.createdAt),
duration: formatDurationText(item.durationSec),
file: item.storageKey,
play: item.status === 'READY' ? '可试听' : zhStatus(item.status),
review: reviewStatus,
score: latestReview?.score ?? '',
issue: latestReview?.notes || '',
result: reviewResultLabel(latestReview?.result),
issueTagsText: Array.isArray(latestReview?.issueTags) ? latestReview.issueTags.join(', ') : '',
samplingText: item.sampling?.selected ? '已抽中' : '未抽中',
samplingRuleText: item.sampling?.matches?.length ? item.sampling.matches.map((match) => match.ruleName).join(', ') : '-',
latestReview,
reviews: item.reviews || [],
};
}
export {
formatCurrency,
formatDate,
formatDateTime,
formatDurationText,
zhStatus,
carrierLabel,
enStatus,
billingModeLabel,
authModeLabel,
normalizeCustomer,
normalizeVendor,
normalizeCustomerGateway,
normalizeVendorGateway,
normalizeLandingLineGroup,
normalizeRecharge,
normalizeUser,
normalizeRole,
normalizeAuditLog,
reviewResultLabel,
reviewResultValue,
normalizeQualityRule,
normalizeRecording,
};
+27 -1
View File
@@ -4,13 +4,14 @@ import { Prisma } from '@lisglosips/database';
import type { ParsedCdrStreamEvent } from '@lisglosips/redis';
import { calculateCycleCharge } from './billing.js';
import { CdrRatingService, type CdrRatingStore, type CdrRatingTransaction, type CreateRatedInput } from './rating.js';
import { CdrRatingService, normalizeCdrNullableId, type CdrRatingStore, type CdrRatingTransaction, type CreateRatedInput } from './rating.js';
class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
rawByEventId = new Map<string, { id: string; eventId: string; ratingStatus: 'UNRATED' | 'RATED' | 'SKIPPED' | 'FAILED' }>();
ratedByRawId = new Map<string, { id: string; rawCdrId: string; customerFee: Prisma.Decimal }>();
vendorGateway = { id: 'vgw_1', vendorId: 'ven_1', billingCycleSec: 6, cycleRate: new Prisma.Decimal('0.012000') };
customer = { id: 'cus_1', balance: new Prisma.Decimal('10.000000') };
createdRawEvents: ParsedCdrStreamEvent[] = [];
async transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T> {
return operation(this);
@@ -23,6 +24,7 @@ class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
async createRaw(event: ParsedCdrStreamEvent, rawCdrId: string) {
const raw = { id: rawCdrId, eventId: event.event_id, ratingStatus: 'UNRATED' as const };
this.rawByEventId.set(event.event_id, raw);
this.createdRawEvents.push(event);
return raw;
}
@@ -73,6 +75,12 @@ function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEv
source_ip: '100.93.185.30',
caller: '1001',
callee: '13800138000',
raw_callee: '67113800138000',
business_prefix_id: 'bp_seed',
business_prefix: '671',
landing_caller: '02160010001',
landing_callee: '8613800138000',
normalized_callee: '13800138000',
callee_city_code: '340100',
callee_city_name: '合肥市',
callee_province_name: '安徽省',
@@ -97,6 +105,15 @@ function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEv
}
describe('S23 CDR rating', () => {
it('normalizes OpenSIPS placeholder ids before writing nullable foreign keys', () => {
expect(normalizeCdrNullableId('none')).toBeNull();
expect(normalizeCdrNullableId('unknown')).toBeNull();
expect(normalizeCdrNullableId('no_active_version')).toBeNull();
expect(normalizeCdrNullableId('no_policy_match')).toBeNull();
expect(normalizeCdrNullableId('single_gateway')).toBeNull();
expect(normalizeCdrNullableId('cgp_1')).toBe('cgp_1');
});
it('calculates Decimal cycle charges with ceiling billing seconds', () => {
const charge = calculateCycleCharge({ durationSec: 28, billingCycleSec: 6, cycleRate: '0.012000' });
@@ -121,6 +138,15 @@ describe('S23 CDR rating', () => {
});
expect(duplicate.outcome).toBe('duplicate');
expect(store.customer.balance.toFixed(6)).toBe('9.940000');
expect(store.createdRawEvents[0]).toMatchObject({
callee: '13800138000',
raw_callee: '67113800138000',
business_prefix_id: 'bp_seed',
business_prefix: '671',
landing_caller: '02160010001',
landing_callee: '8613800138000',
normalized_callee: '13800138000'
});
});
it('skips failed or zero-duration CDRs without balance changes', async () => {
+27 -9
View File
@@ -176,20 +176,25 @@ class PrismaCdrRatingTransaction implements CdrRatingTransaction {
id: rawCdrId,
eventId: storageEventId(event.event_id),
callId: event.call_id,
customerId: nullableId(event.customer_id),
customerGatewayId: nullableId(event.customer_gateway_id),
customerGatewayPolicyId: nullableId(event.customer_gateway_policy_id),
customerId: normalizeCdrNullableId(event.customer_id),
customerGatewayId: normalizeCdrNullableId(event.customer_gateway_id),
customerGatewayPolicyId: normalizeCdrNullableId(event.customer_gateway_policy_id),
sourceIp: emptyToNull(event.source_ip),
caller: event.caller || 'unknown',
callee: event.callee || 'unknown',
rawCallee: emptyToNull(event.raw_callee),
businessPrefixId: normalizeCdrNullableId(event.business_prefix_id),
businessPrefix: emptyToNull(event.business_prefix),
calleeCityCode: emptyToNull(event.callee_city_code),
calleeCityName: emptyToNull(event.callee_city_name),
calleeProvinceName: emptyToNull(event.callee_province_name),
calleeOperator: numberCarrier(event.callee_operator),
calleeNumberType: phoneNumberType(event.callee_number_type),
vendorId: nullableId(event.vendor_id),
vendorGatewayId: nullableId(event.vendor_gateway_id),
lineGroupId: nullableId(event.line_group_id),
vendorId: normalizeCdrNullableId(event.vendor_id),
vendorGatewayId: normalizeCdrNullableId(event.vendor_gateway_id),
lineGroupId: normalizeCdrNullableId(event.line_group_id),
landingCaller: emptyToNull(event.landing_caller),
landingCallee: emptyToNull(event.landing_callee),
startedAt: parseCdrDate(event.started_at, event.created_at),
answeredAt: parseOptionalCdrDate(event.answered_at),
endedAt: parseCdrDate(event.ended_at, event.created_at),
@@ -274,12 +279,19 @@ function prefixedId(prefix: string): string {
}
function billableId(value: string): boolean {
return Boolean(nullableId(value));
return Boolean(normalizeCdrNullableId(value));
}
function nullableId(value: string): string | null {
export function normalizeCdrNullableId(value: string): string | null {
const normalized = value.trim();
if (!normalized || normalized === 'none' || normalized === 'unknown' || normalized === 'no_active_version') {
if (
!normalized ||
normalized === 'none' ||
normalized === 'unknown' ||
normalized === 'no_active_version' ||
normalized === 'no_policy_match' ||
normalized === 'single_gateway'
) {
return null;
}
return normalized;
@@ -330,6 +342,12 @@ function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
source_ip: event.source_ip,
caller: event.caller,
callee: event.callee,
raw_callee: event.raw_callee,
business_prefix_id: event.business_prefix_id,
business_prefix: event.business_prefix,
landing_caller: event.landing_caller,
landing_callee: event.landing_callee,
normalized_callee: event.normalized_callee,
callee_city_code: event.callee_city_code,
callee_city_name: event.callee_city_name,
callee_province_name: event.callee_province_name,
@@ -59,9 +59,38 @@ function prismaFixture(): PrismaClient {
{ id: 'cus_1', status: 'ENABLED', balance: new Prisma.Decimal('10'), creditLimit: new Prisma.Decimal('0'), minBalance: new Prisma.Decimal('0') }
]
},
businessPrefix: {
findMany: async () => [
{ id: 'bp_1', prefix: '671', name: '业务671', priority: 10, status: 'ENABLED' }
]
},
customerGateway: {
findMany: async () => [
{ id: 'cgw_1', customerId: 'cus_1', authMode: 'IP', sourceIp: '100.93.185.30', sipUsername: null, sipDomain: null, sipHa1: null, status: 'ENABLED' }
{
id: 'cgw_1',
customerId: 'cus_1',
authMode: 'IP',
sourceIp: '100.93.185.30',
ips: [
{ sourceIp: '100.93.185.30' },
{ sourceIp: '100.93.185.31' }
],
sipUsername: null,
sipDomain: null,
sipHa1: null,
lineGroupId: 'lg_1',
billingCycleSec: 6,
cycleRate: new Prisma.Decimal('0.008'),
callerMatchMode: 'PREFIXES',
callerPrefixes: [{ prefix: '021' }],
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixes: [
{
businessPrefix: { id: 'bp_1', prefix: '671', name: '业务671', priority: 10, status: 'ENABLED' }
}
],
status: 'ENABLED'
}
]
},
customerGatewayPolicy: {
@@ -95,10 +124,15 @@ function prismaFixture(): PrismaClient {
concurrencyLimit: 30,
billingCycleSec: 6,
cycleRate: new Prisma.Decimal('0.012'),
landingCalleePrefix: '86',
status: 'ENABLED',
forbiddenPeriods: [],
codecs: [],
prefixRules: [],
callerRewritePool: [
{ caller: '02160010001', weight: 70, status: 'ENABLED' },
{ caller: '02160010002', weight: 30, status: 'ENABLED' }
],
blockedRegions: [{ regionScope: 'CITY', provinceCode: '340000', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' }]
}
]
@@ -141,6 +175,12 @@ describe('S42 config publisher number library snapshot', () => {
expect(result.published).toBe(true);
expect(result.manifest).toMatchObject({
schemaVersion: 2,
businessPrefixCount: 1,
customerGatewayIpCount: 2,
customerGatewayBusinessPrefixCount: 1,
customerGatewayCallerPrefixCount: 1,
callerRewriteCount: 2,
cityCount: 1,
phoneSegmentCount: 1,
areaCodeCount: 1,
@@ -149,6 +189,14 @@ describe('S42 config publisher number library snapshot', () => {
});
expect(redis.writes).toEqual(
expect.arrayContaining([
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:business_prefix:bp_1` }),
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:business_prefix_value:671`, value: 'bp_1' }),
expect.objectContaining({ command: 'rpush', key: `cfg:v:${version}:business_prefixes` }),
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:auth:ip:100.93.185.30`, value: 'cgw_1' }),
expect.objectContaining({ command: 'rpush', key: `cfg:v:${version}:auth:ip:100.93.185.31:gateways`, value: 'cgw_1' }),
expect.objectContaining({ command: 'rpush', key: `cfg:v:${version}:customer_gateway:cgw_1:caller_prefixes`, value: '021' }),
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:customer_gateway:cgw_1:line_group`, value: 'lg_1' }),
expect.objectContaining({ command: 'rpush', key: `cfg:v:${version}:vendor_gateway:vgw_1:caller_rewrite_pool` }),
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:phone_segment:1380013` }),
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:area_code:0551` }),
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:carrier_prefix:138` }),
+102 -5
View File
@@ -17,12 +17,18 @@ export interface PublishConfigResult {
}
export interface ConfigManifest {
schemaVersion: number;
version: string;
generatedAt: string;
customerCount: number;
businessPrefixCount: number;
gatewayCount: number;
customerGatewayIpCount: number;
customerGatewayBusinessPrefixCount: number;
customerGatewayCallerPrefixCount: number;
policyCount: number;
vendorGatewayCount: number;
callerRewriteCount: number;
lineGroupCount: number;
cityCount: number;
phoneSegmentCount: number;
@@ -40,14 +46,29 @@ type ConfigSnapshot = {
creditLimit: string;
minBalance: string;
}>;
businessPrefixes: Array<{
id: string;
prefix: string;
name: string;
priority: number;
status: string;
}>;
gateways: Array<{
id: string;
customerId: string;
authMode: string;
sourceIp: string | null;
sourceIps: string[];
sipUsername: string | null;
sipDomain: string | null;
sipHa1: string | null;
lineGroupId: string | null;
billingCycleSec: number;
cycleRate: string;
callerMatchMode: string;
callerPrefixes: string[];
calleeMatchMode: string;
businessPrefixes: Array<{ id: string; prefix: string; name: string; priority: number; status: string }>;
status: string;
}>;
policies: Array<{
@@ -75,10 +96,12 @@ type ConfigSnapshot = {
concurrencyLimit: number;
billingCycleSec: number;
cycleRate: string;
landingCalleePrefix: string | null;
status: string;
forbiddenPeriods: Array<{ weekdayMask: number; startTime: string; endTime: string }>;
codecs: Array<{ codec: string; priority: number }>;
prefixRules: Array<{ direction: string; matchPrefix: string; replacePrefix: string; priority: number }>;
callerRewritePool: Array<{ caller: string; weight: number; status: string }>;
blockedRegions: Array<{
regionScope: string;
provinceCode: string | null;
@@ -136,7 +159,7 @@ type ConfigSnapshot = {
export async function publishPendingConfig(prisma: PrismaClient, redis: RedisClient, now = new Date()): Promise<PublishConfigResult> {
const pending = await prisma.outboxEvent.findMany({
where: {
aggregateType: { in: ['customer_gateway_config', 'vendor_gateway_config', 'line_group_config', 'number_library_config'] },
aggregateType: { in: ['business_prefix_config', 'customer_gateway_config', 'vendor_gateway_config', 'line_group_config', 'number_library_config'] },
status: 'PENDING',
availableAt: { lte: now }
},
@@ -217,14 +240,30 @@ export async function rollbackActiveConfig(redis: RedisClient): Promise<{ rolled
}
async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
const [customers, gateways, policies, vendorGateways, lineGroups, cities, phoneSegments, areaCodes, carrierPrefixRules] = await prisma.$transaction([
const [customers, businessPrefixes, gateways, policies, vendorGateways, lineGroups, cities, phoneSegments, areaCodes, carrierPrefixRules] = await prisma.$transaction([
prisma.customer.findMany({
where: { deletedAt: null },
orderBy: [{ id: 'asc' }]
}),
prisma.businessPrefix.findMany({
where: { deletedAt: null, status: 'ENABLED' },
orderBy: [{ priority: 'asc' }, { prefix: 'asc' }]
}),
prisma.customerGateway.findMany({
where: { deletedAt: null },
orderBy: [{ id: 'asc' }]
orderBy: [{ id: 'asc' }],
include: {
ips: { where: { deletedAt: null, status: 'ENABLED' }, orderBy: [{ createdAt: 'asc' }] },
callerPrefixes: { orderBy: [{ priority: 'asc' }, { prefix: 'asc' }] },
businessPrefixes: {
orderBy: [{ createdAt: 'asc' }],
include: {
businessPrefix: {
select: { id: true, prefix: true, name: true, priority: true, status: true }
}
}
}
}
}),
prisma.customerGatewayPolicy.findMany({
where: { deletedAt: null },
@@ -237,6 +276,7 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
codecs: { orderBy: [{ priority: 'asc' }] },
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] },
callerRewritePool: { where: { deletedAt: null, status: 'ENABLED' }, orderBy: [{ weight: 'desc' }, { caller: 'asc' }] },
blockedRegions: { orderBy: [{ regionScope: 'asc' }, { provinceCode: 'asc' }, { cityCode: 'asc' }] }
}
}),
@@ -275,14 +315,35 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
creditLimit: customer.creditLimit.toFixed(6),
minBalance: customer.minBalance.toFixed(6)
})),
businessPrefixes: businessPrefixes.map((prefixItem) => ({
id: prefixItem.id,
prefix: prefixItem.prefix,
name: prefixItem.name,
priority: prefixItem.priority,
status: prefixItem.status
})),
gateways: gateways.map((gateway) => ({
id: gateway.id,
customerId: gateway.customerId,
authMode: gateway.authMode,
sourceIp: gateway.sourceIp,
sourceIps: gateway.ips.length ? gateway.ips.map((ip) => ip.sourceIp) : gateway.sourceIp ? [gateway.sourceIp] : [],
sipUsername: gateway.sipUsername,
sipDomain: gateway.sipDomain,
sipHa1: gateway.sipHa1,
lineGroupId: gateway.lineGroupId,
billingCycleSec: gateway.billingCycleSec,
cycleRate: gateway.cycleRate.toFixed(6),
callerMatchMode: gateway.callerMatchMode,
callerPrefixes: gateway.callerPrefixes.map((item) => item.prefix),
calleeMatchMode: gateway.calleeMatchMode,
businessPrefixes: gateway.businessPrefixes.map((item) => ({
id: item.businessPrefix.id,
prefix: item.businessPrefix.prefix,
name: item.businessPrefix.name,
priority: item.businessPrefix.priority,
status: item.businessPrefix.status
})),
status: gateway.status
})),
policies: policies.map((policy) => ({
@@ -310,6 +371,7 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
concurrencyLimit: gateway.concurrencyLimit,
billingCycleSec: gateway.billingCycleSec,
cycleRate: gateway.cycleRate.toFixed(6),
landingCalleePrefix: gateway.landingCalleePrefix,
status: gateway.status,
forbiddenPeriods: gateway.forbiddenPeriods.map((period) => ({
weekdayMask: period.weekdayMask,
@@ -326,6 +388,11 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
replacePrefix: rule.replacePrefix,
priority: rule.priority
})),
callerRewritePool: gateway.callerRewritePool.map((caller) => ({
caller: caller.caller,
weight: caller.weight,
status: caller.status
})),
blockedRegions: gateway.blockedRegions.map((region) => ({
regionScope: region.regionScope,
provinceCode: region.provinceCode,
@@ -391,12 +458,18 @@ async function writeSnapshot(
const prefix = configVersionPrefix(version);
const checksum = crypto.createHash('sha256').update(stableJson(snapshot)).digest('hex');
const manifest: ConfigManifest = {
schemaVersion: 2,
version,
generatedAt: now.toISOString(),
customerCount: snapshot.customers.length,
businessPrefixCount: snapshot.businessPrefixes.length,
gatewayCount: snapshot.gateways.length,
customerGatewayIpCount: snapshot.gateways.reduce((sum, gateway) => sum + gateway.sourceIps.length, 0),
customerGatewayBusinessPrefixCount: snapshot.gateways.reduce((sum, gateway) => sum + gateway.businessPrefixes.length, 0),
customerGatewayCallerPrefixCount: snapshot.gateways.reduce((sum, gateway) => sum + gateway.callerPrefixes.length, 0),
policyCount: snapshot.policies.length,
vendorGatewayCount: snapshot.vendorGateways.length,
callerRewriteCount: snapshot.vendorGateways.reduce((sum, gateway) => sum + gateway.callerRewritePool.length, 0),
lineGroupCount: snapshot.lineGroups.length,
cityCount: snapshot.cities.length,
phoneSegmentCount: snapshot.phoneSegments.length,
@@ -412,10 +485,31 @@ async function writeSnapshot(
for (const customer of snapshot.customers) {
multi.set(`${prefix}:customer:${customer.id}`, JSON.stringify(customer));
}
for (const businessPrefix of snapshot.businessPrefixes) {
multi.set(`${prefix}:business_prefix:${businessPrefix.id}`, JSON.stringify(businessPrefix));
multi.set(`${prefix}:business_prefix_value:${businessPrefix.prefix}`, businessPrefix.id);
multi.rpush(`${prefix}:business_prefixes`, JSON.stringify(businessPrefix));
}
for (const gateway of snapshot.gateways) {
multi.set(`${prefix}:customer_gateway:${gateway.id}`, JSON.stringify(gateway));
if ((gateway.authMode === 'IP' || gateway.authMode === 'MIXED') && gateway.sourceIp) {
multi.set(`${prefix}:auth:ip:${gateway.sourceIp}`, gateway.id);
if (gateway.callerMatchMode === 'PREFIXES') {
for (const callerPrefix of gateway.callerPrefixes) {
multi.rpush(`${prefix}:customer_gateway:${gateway.id}:caller_prefixes`, callerPrefix);
}
}
if (gateway.calleeMatchMode === 'BUSINESS_PREFIXES') {
for (const businessPrefix of gateway.businessPrefixes) {
multi.rpush(`${prefix}:customer_gateway:${gateway.id}:business_prefixes`, JSON.stringify(businessPrefix));
}
}
if (gateway.lineGroupId) {
multi.set(`${prefix}:customer_gateway:${gateway.id}:line_group`, gateway.lineGroupId);
}
if (gateway.authMode === 'IP' || gateway.authMode === 'MIXED') {
for (const sourceIp of gateway.sourceIps) {
multi.set(`${prefix}:auth:ip:${sourceIp}`, gateway.id);
multi.rpush(`${prefix}:auth:ip:${sourceIp}:gateways`, gateway.id);
}
}
if ((gateway.authMode === 'SIP_DIGEST' || gateway.authMode === 'MIXED') && gateway.sipUsername && gateway.sipDomain) {
multi.set(`${prefix}:auth:sip:${gateway.sipUsername}@${gateway.sipDomain}`, gateway.id);
@@ -426,6 +520,9 @@ async function writeSnapshot(
}
for (const gateway of snapshot.vendorGateways) {
multi.set(`${prefix}:vendor_gateway:${gateway.id}`, JSON.stringify(gateway));
for (const callerRewrite of gateway.callerRewritePool) {
multi.rpush(`${prefix}:vendor_gateway:${gateway.id}:caller_rewrite_pool`, JSON.stringify(callerRewrite));
}
for (const region of gateway.blockedRegions) {
if (region.regionScope === 'CITY' && region.cityCode) {
multi.sadd(`${prefix}:vendor_gateway:${gateway.id}:blocked_city_codes`, region.cityCode);
@@ -0,0 +1,191 @@
# 业务前缀重构第 8 步迁移与发布前验收 Runbook
## 范围
本文用于业务前缀、客户网关匹配和落地号码改写重构的发布前准备。当前仍以本地 KVM/Tailscale 环境为准,不进入阿里云生产迁移。
本步骤覆盖:
- B 执行新增 Prisma migration 前后的检查。
- 旧客户网关单 IP 和旧 `customer_gateway_policies` 到二期模型的安全迁移。
- Redis 配置发布检查。
- A OpenSIPS/Lua 替换前语法检查和回滚点。
- A/B/T 端到端验收清单。
## 影响、风险与回滚点
影响范围:
- MySQL:新增二期表/列后,`customer_gateways.line_group_id``customer_gateway_ips``customer_gateway_caller_prefixes` 会被迁移脚本写入。
- Redis:迁移脚本成功应用后会写 `customer_gateway_config` outboxconfig-publisher 会发布新版本配置。
- OpenSIPS:后续发布 A 侧 `opensips.cfg``lisglosips_hotpath.lua` 后,INVITE 鉴权、路由、主被叫改写进入新热路径。
- CDR:第 7 步已让 Worker 可写入原始被叫、业务前缀、落地主被叫字段。
主要风险:
- 多条旧策略无法自动合并到“一个客户网关一个线路组”,必须人工拆分或确认保留规则。
- 旧 `calleeMode=EQUALS/PREFIX` 不能安全推断为业务前缀,必须先在“业务前缀管理”中建立前缀并重新绑定客户网关。
- 旧 `callerMode=EQUALS` 在新模型下没有严格等值语义,不能自动迁移为前缀,否则可能扩大匹配范围。
- A 侧 OpenSIPS 配置替换会影响现有通话,必须维护窗口或确认可接受。
回滚点:
- B 数据库:执行 migration 前必须按 S30 Runbook 备份 MySQL;迁移脚本 apply 前需保存 dry-run 报告。
- B release:保留当前 `/opt/lisglosips/current` 指向,可通过 S30 rollback 脚本切回旧 release。
- Redis:记录 `cfg:active_version``cfg:previous_version`,必要时切回上一版本。
- A OpenSIPS:替换前备份 `/etc/opensips/opensips.cfg``/etc/opensips/lisglosips_hotpath.lua`;语法检查失败不得重启;重启后失败则恢复备份并再次 `opensips -C`
## 迁移脚本
本仓库新增:
```bash
scripts/phase2-gateway-migration.mjs
```
默认 dry-run,不写库:
```bash
node scripts/phase2-gateway-migration.mjs --json
```
显式 apply 才写库:
```bash
node scripts/phase2-gateway-migration.mjs --apply --json
```
脚本只自动迁移满足以下条件的客户网关:
- 每个客户网关恰好一条启用旧策略。
- 旧策略 `calleeMode=ANY`
- 旧策略 `callerMode=ANY`,或 `callerMode=PREFIX``callerValue` 非空。
脚本会拒绝自动迁移以下情况:
- 多条启用旧策略。
- 没有启用旧策略。
- 旧被叫等值或前缀匹配。
- 旧主叫等值匹配。
- 新旧线路组字段冲突。
apply 成功后,脚本会写入一个 `customer_gateway_config` outbox,用于触发配置发布。
## B 侧发布前步骤
1. 记录状态:
```bash
readlink -f /opt/lisglosips/current
mysql -NBe "SELECT migration_name, finished_at FROM _prisma_migrations ORDER BY finished_at DESC LIMIT 5;"
redis-cli --no-auth-warning GET cfg:active_version
```
2. 执行 S30 备份:
```bash
sudo /opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
sudo /usr/local/sbin/lisglosips-mysql-backup
sudo /usr/local/sbin/lisglosips-redis-backup
```
3. 部署新 release 到独立目录,不在线修改 current。
4. 执行数据库 migration
```bash
export DATABASE_URL='mysql://<MIGRATE_USER>@127.0.0.1:3306/lisglosips'
pnpm exec prisma migrate deploy
pnpm exec prisma migrate status
```
5. 迁移脚本 dry-run
```bash
export DATABASE_URL='mysql://<MIGRATE_USER>@127.0.0.1:3306/lisglosips'
node scripts/phase2-gateway-migration.mjs --json > /tmp/phase2-gateway-migration.dry-run.json
```
6. 若 `blockers` 非空,先人工处理,不执行 apply。
7. 确认 dry-run 后执行 apply
```bash
node scripts/phase2-gateway-migration.mjs --apply --json > /tmp/phase2-gateway-migration.apply.json
```
8. 等待或触发 config-publisher,检查新配置发布:
```bash
systemctl status lisglosips@config-publisher --no-pager
redis-cli --no-auth-warning GET cfg:active_version
```
## A 侧 OpenSIPS 发布前步骤
1. 备份:
```bash
sudo install -d -m 0750 /var/backups/lisglosips-phase2
sudo cp -a /etc/opensips/opensips.cfg /var/backups/lisglosips-phase2/opensips.cfg.$(date +%Y%m%d%H%M%S)
sudo cp -a /etc/opensips/lisglosips_hotpath.lua /var/backups/lisglosips-phase2/lisglosips_hotpath.lua.$(date +%Y%m%d%H%M%S)
```
2. 替换候选文件后,加载 Lua 并确认 SHA 与 `opensips.cfg``EVALSHA` 一致。
3. 必须先执行语法检查:
```bash
sudo opensips -C -f /etc/opensips/opensips.cfg
```
4. 语法检查通过后才可重启:
```bash
sudo systemctl restart opensips
sudo systemctl is-active opensips
```
## A/B/T 验收清单
至少验证以下呼叫:
- 命中业务前缀:例如客户送 `67118822190000`,话单 `callee=18822190000``raw_callee=67118822190000``business_prefix=671`
- 任意被叫规则:客户网关 `calleeMatchMode=ANY` 时,旧无业务前缀呼叫仍可路由。
- 主叫前缀命中:配置 `callerPrefixes` 后,命中前缀允许呼叫。
- 主叫前缀不命中:返回 `CALLER_PREFIX_NOT_MATCHED`
- 业务前缀不命中:返回 `BUSINESS_PREFIX_NOT_MATCHED`
- 落地被叫前缀:落地侧实际 Request-URI 被叫包含 `landingCalleePrefix`
- 落地指定主叫:落地侧 From 主叫按权重池选择,话单 `landing_caller` 有值。
- 屏蔽地区跳过:命中落地网关屏蔽地区时尝试同线路组下一网关。
- 当前通话页:呼叫过程中可看到当前通话,强制挂断可用。
- 话单详情:可看到原始被叫、业务前缀、落地主叫、落地被叫、地级市、运营商。
## 回滚
B 应用回滚:
```bash
sudo infra/server-b/s30/lisglosips-release-rollback.sh /opt/lisglosips/releases/<previous-release>
```
Redis 配置回滚:
```bash
# 使用当前系统已验证的 Redis 运维方式切回 cfg:previous_version
```
A OpenSIPS 回滚:
```bash
sudo cp -a /var/backups/lisglosips-phase2/<opensips.cfg.backup> /etc/opensips/opensips.cfg
sudo cp -a /var/backups/lisglosips-phase2/<lisglosips_hotpath.lua.backup> /etc/opensips/lisglosips_hotpath.lua
sudo opensips -C -f /etc/opensips/opensips.cfg
sudo systemctl restart opensips
```
数据库回滚原则:
- 本次 migration 为向前兼容新增表/列,不做破坏性 down migration。
- 若 apply 后发现业务规则错误,优先通过客户网关页面/API 修正 `lineGroupId`、主叫规则和业务前缀绑定,再重新发布 Redis 配置。
- 若必须恢复数据,使用执行前 MySQL 备份在隔离环境验证后再恢复。
+107
View File
@@ -0,0 +1,107 @@
# S42 二期业务前缀基线冻结报告
## 1. 目标
冻结 LisgloSIPS V2 本地 KVM/Tailscale 环境中二期业务前缀、客户网关匹配、号码库、落地号码改写和真实话单接入后的当前可回滚基线。
本冻结不进入阿里云生产迁移,不替代未来生产环境的 VPC、安全组、正式 DNS/TLS、独立数据盘、备份恢复和灰度验收。
## 2. 冻结结论
当前 `s42-phase2-business-prefix-20260624140000` 具备冻结条件,可作为“二期业务前缀小样本联调基线”。
本冻结不包含真实 80 万手机号段导入,不包含生产式号码库压测,不包含浏览器登录态逐页按钮全量人工复测。
## 3. 冻结清单
| 项 | 当前值 |
| --- | --- |
| B current release | `/opt/lisglosips/releases/s42-phase2-business-prefix-20260624140000` |
| Redis active version | `1782286812696` |
| Redis previous version | `1782286145681` |
| A OpenSIPS cfg SHA-256 | `ae1544e9a39d2db114e6981babac042c2ab6d399d59ba597a52b85d597aa5aa5` |
| A Lua SHA-1 | `0b7ee595cdcad2f38a670dd8ff3553387820039e` |
| Prisma migrations | 4 条,B 当前库 `Database schema is up to date` |
| 最新 MySQL 备份 | `/data/backups/mysql/20260624T055810Z`SHA256 校验通过 |
| 最新 Redis 备份 | `/data/backups/redis/20260624T055810Z`SHA256 校验通过 |
| T 测试脚本 | `/opt/lisglosips-s28/lisglosips-s28-sip.py` |
## 4. 验证结果
本地代码验证:
- `corepack pnpm@10.33.0 typecheck` 通过。
- `corepack pnpm@10.33.0 lint` 通过。
- `corepack pnpm@10.33.0 build` 通过。
- `corepack pnpm@10.33.0 exec vitest run --hookTimeout=60000 --maxWorkers=1` 通过:25 个测试文件,87 条测试通过。
B 侧验证:
- `lisglosips@api``lisglosips@cdr-worker``lisglosips@recording-worker``lisglosips@config-publisher``nginx``mysql``redis-server``heplify-server``lisglosips-prometheus``grafana-server` 均为 `active`
- `https://127.0.0.1/api/v2/health/ready` 返回 `ok`
- `infra/server-b/s30/lisglosips-release-preflight.sh` 已补齐执行权限,直接执行通过。
- Redis `stream:cdr_deadletter` 长度为 26,抽样均为 2026-06-23 至 2026-06-24 早期修复前历史死信,包括 `config_version` INT 越界、旧占位外键失败、旧 `NO_POLICY` 事件;冻结验收实呼未新增 deadletter。
A 侧验证:
- `opensips``lisglosips-redis-auth-proxy``rtpengine``rtpengine-recording-daemon` 均为 `active`
- `opensips -C -f /etc/opensips/opensips.cfg` 通过。
- 历史 4 条 dialog 经 `dlg_end_dlg` 无法清理,返回 OpenSIPS MI `Operation failed`;已在语法检查通过后重启 `opensips` 清空。
- 冻结验收实呼后出现 1 条测试 dialog 残留;已再次语法检查并重启 `opensips`,最终 `dlg_list` 为空。
T 侧验证:
- `opensips``active`
- `100.93.185.30:5060``100.93.185.30:50620` 均在 UDP 监听。
端到端小样本呼叫:
- Call-ID`s28-1782288889943-ckv83lce@lisglosips-t`
- SIP`100 Giving it a try``200 OK`、BYE `200 OK`
- B raw CDR`raw_d5732df32b8d496686512870fb087232`
- 话单字段:`caller=s28-ip-1001``callee=13800138000``raw_callee=13800138000``business_prefix=NULL``landing_caller=s28-ip-1001``landing_callee=13800138000``sip_code=200``rating_status=RATED`
- 计费:客户费用 `0.012000`,供应商成本 `0.012000`
- CDR Worker`processed=1``deadlettered=0`
## 5. 已处理冻结前问题
1. 历史 dialog 残留:已通过重启 A OpenSIPS 清空,最终 `dlg_list` 为空。
2. CDR deadletter:未删除历史死信,已确认样本属于早期修复前历史事件;冻结验收呼叫未新增 deadletter。
3. B preflight 执行权限:已将 `/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh` 权限修正为 `0755`,直接执行通过。
4. 全量 Vitest:初次使用默认 hook timeout 时 `app.e2e.spec.ts` 超时;使用 `--hookTimeout=60000` 后全量测试通过。
5. 冻结范围:明确不包含真实 80 万手机号段导入和浏览器登录态逐页按钮全量人工复测。
## 6. 回滚点
B 应用回滚:
```bash
sudo /opt/lisglosips/current/infra/server-b/s30/lisglosips-release-rollback.sh /opt/lisglosips/releases/s33-active-calls-20260623094000
```
Redis 配置回滚:
- 当前版本:`1782286812696`
- 上一版本:`1782286145681`
- 回滚时切回 `cfg:previous_version`,不删除 Stream 或 deadletter。
A OpenSIPS 回滚:
- 当前配置语法已通过,当前 Lua SHA 已记录。
- 若需回滚,使用最近一次 `/var/backups/lisglosips-phase2/` 下的 `opensips.cfg``lisglosips_hotpath.lua` 备份,先执行 `opensips -C -f /etc/opensips/opensips.cfg`,通过后重启 `opensips`
数据库回滚:
- 二期 migration 为向前兼容新增表/列,不建议执行破坏性 down migration。
- 若必须恢复数据,使用 `/data/backups/mysql/20260624T055810Z`,先恢复到隔离库验证后再进入维护窗口处理。
## 7. 遗留风险
- 真实 80 万手机号段尚未导入;号码库相关性能、Redis 快照体量和导入失败明细仍需单独验收。
- 浏览器登录态逐页按钮未做完整人工复测;本次只完成 API、构建、单元/集成测试和端到端小样本呼叫验证。
- OpenSIPS dialog 在测试呼叫结束后仍可能短期残留,当前通过重启清空。后续应单独分析 dialog 生命周期和 `dlg_end_dlg Operation failed` 的根因。
- 当前仍为本地 KVM/Tailscale 环境,开发 CA、Tailscale IP、本地根盘布局不能原样用于阿里云生产。
## 8. 结论
`s42-phase2-business-prefix-20260624140000` 可冻结为二期业务前缀小样本联调基线。后续需求或 Bug 修复应创建新的 release,不再覆盖该冻结点;阿里云迁移前仍需重新执行 S30 Runbook、备份恢复演练、正式网络/TLS 验收和灰度观察。
+231 -84
View File
@@ -1,37 +1,30 @@
local active = redis.call('GET', KEYS[1])
if not active or active == '' then
return {
'reject',
'CONFIG_MISSING',
'none',
'none',
'none',
'none',
'no_active_version',
'none',
'none',
'none',
'none',
'UNKNOWN',
'UNKNOWN',
'UNKNOWN',
'UNKNOWN',
'UNKNOWN',
'UNKNOWN',
''
}
end
local source_ip = ARGV[1]
local source_ip = ARGV[1] or ''
local caller = ARGV[2] or ''
local callee = ARGV[3] or ''
local raw_callee = ARGV[3] or ''
local call_id = ARGV[4] or ''
local prefix = 'cfg:v:' .. active
local prefix = active and active ~= '' and ('cfg:v:' .. active) or ''
local number_info = nil
local function field(json, name)
if not json then
return nil
end
return string.match(json, '"' .. name .. '":"([^"]*)"')
end
local function number_field(json, name)
if not json then
return nil
end
return string.match(json, '"' .. name .. '":([0-9]+)')
end
local function starts_with(value, prefix_value)
return prefix_value ~= '' and string.sub(value, 1, string.len(prefix_value)) == prefix_value
end
local function normalize_callee(value)
local digits = string.gsub(value or '', '[^0-9]', '')
if string.sub(digits, 1, 4) == '0086' then
@@ -123,9 +116,49 @@ local function resolve_number(value)
return info
end
local number_info = resolve_number(callee)
local function ensure_number_info(callee_value)
if not number_info then
number_info = resolve_number(callee_value)
end
return number_info
end
local function result(decision, reason, gateway_id, version, line_group_id, policy_id, customer_id, vendor_id, vendor_gateway_id, host, port)
local function hash_value(value)
local hash = 0
for index = 1, string.len(value or '') do
hash = (hash * 31 + string.byte(value, index)) % 2147483647
end
return hash
end
local function choose_landing_caller(vendor_gateway_id)
local pool = redis.call('LRANGE', prefix .. ':vendor_gateway:' .. vendor_gateway_id .. ':caller_rewrite_pool', 0, -1)
local total_weight = 0
local choices = {}
for _, item_json in ipairs(pool) do
if string.find(item_json, '"status":"ENABLED"', 1, true) or not string.find(item_json, '"status"', 1, true) then
local rewrite_caller = field(item_json, 'caller')
local weight = tonumber(number_field(item_json, 'weight') or '0') or 0
if rewrite_caller and rewrite_caller ~= '' and weight > 0 then
total_weight = total_weight + weight
choices[#choices + 1] = {rewrite_caller, total_weight}
end
end
end
if total_weight <= 0 then
return caller
end
local slot = (hash_value(call_id .. ':' .. vendor_gateway_id) % total_weight) + 1
for _, choice in ipairs(choices) do
if slot <= choice[2] then
return choice[1]
end
end
return choices[#choices][1]
end
local function result(decision, reason, gateway_id, version, line_group_id, policy_id, customer_id, vendor_id, vendor_gateway_id, host, port, real_callee, business_prefix_id, business_prefix, landing_caller, landing_callee)
local info = ensure_number_info(real_callee or raw_callee)
return {
decision,
reason,
@@ -138,51 +171,90 @@ local function result(decision, reason, gateway_id, version, line_group_id, poli
vendor_gateway_id,
host,
port,
number_info.city_code,
number_info.city_name,
number_info.province_name,
number_info.carrier,
number_info.number_type,
number_info.province_code,
number_info.normalized
info.city_code,
info.city_name,
info.province_name,
info.carrier,
info.number_type,
info.province_code,
real_callee or raw_callee,
raw_callee,
business_prefix_id or 'none',
business_prefix or 'none',
landing_caller or caller,
landing_callee or info.normalized,
info.normalized ~= '' and info.normalized or 'none'
}
end
local gateway_id = redis.call('GET', prefix .. ':auth:ip:' .. source_ip)
if not gateway_id then
return result('reject', 'AUTH_MISSING', 'none', active, 'none', 'none', 'no_auth_ip', 'none', 'none', 'none', 'none')
if not active or active == '' then
return result('reject', 'CONFIG_MISSING', 'none', 'no_active_version', 'none', 'none', 'no_active_version', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end
local gateway_json = redis.call('GET', prefix .. ':customer_gateway:' .. gateway_id)
if not gateway_json then
return result('reject', 'GATEWAY_MISSING', gateway_id, active, 'none', 'none', 'gateway_missing', 'none', 'none', 'none', 'none')
end
if not string.find(gateway_json, '"status":"ENABLED"', 1, true) then
return result('reject', 'GATEWAY_DISABLED', gateway_id, active, 'none', 'none', 'gateway_disabled', 'none', 'none', 'none', 'none')
local function customer_ok(gateway_id, gateway_json)
local customer_id = field(gateway_json, 'customerId')
if not customer_id then
return nil, 'CUSTOMER_MISSING', 'customer_id_missing'
end
local customer_json = redis.call('GET', prefix .. ':customer:' .. customer_id)
if not customer_json then
return nil, 'CUSTOMER_MISSING', 'customer_missing'
end
if not string.find(customer_json, '"status":"ENABLED"', 1, true) then
return nil, 'CUSTOMER_DISABLED', 'customer_disabled'
end
return customer_id, nil, nil
end
local customer_id = string.match(gateway_json, '"customerId":"([^"]+)"')
if not customer_id then
return result('reject', 'CUSTOMER_MISSING', gateway_id, active, 'none', 'none', 'customer_id_missing', 'none', 'none', 'none', 'none')
local function match_caller(gateway_id, gateway_json)
local mode = field(gateway_json, 'callerMatchMode') or 'ANY'
if mode == 'ANY' then
return true
end
if mode == 'PREFIXES' then
local prefixes = redis.call('LRANGE', prefix .. ':customer_gateway:' .. gateway_id .. ':caller_prefixes', 0, -1)
for _, caller_prefix in ipairs(prefixes) do
if starts_with(caller, caller_prefix) then
return true
end
end
end
return false
end
local customer_json = redis.call('GET', prefix .. ':customer:' .. customer_id)
if not customer_json then
return result('reject', 'CUSTOMER_MISSING', gateway_id, active, 'none', 'none', 'customer_missing', 'none', 'none', 'none', 'none')
end
if not string.find(customer_json, '"status":"ENABLED"', 1, true) then
return result('reject', 'CUSTOMER_DISABLED', gateway_id, active, 'none', 'none', 'customer_disabled', 'none', 'none', 'none', 'none')
local function match_callee(gateway_id, gateway_json)
local mode = field(gateway_json, 'calleeMatchMode') or 'ANY'
if mode == 'ANY' then
return raw_callee, 'none', 'none'
end
if mode == 'BUSINESS_PREFIXES' then
local prefixes = redis.call('LRANGE', prefix .. ':customer_gateway:' .. gateway_id .. ':business_prefixes', 0, -1)
local best_prefix = ''
local best_prefix_id = 'none'
for _, item_json in ipairs(prefixes) do
local item_prefix = field(item_json, 'prefix') or ''
if starts_with(raw_callee, item_prefix) and string.len(item_prefix) > string.len(best_prefix) then
best_prefix = item_prefix
best_prefix_id = field(item_json, 'id') or 'none'
end
end
if best_prefix ~= '' then
return string.sub(raw_callee, string.len(best_prefix) + 1), best_prefix_id, best_prefix
end
end
return nil, 'none', 'none'
end
local function region_blocked(vendor_gateway_id)
if number_info.city_code ~= 'UNKNOWN' then
local city_blocked = redis.call('SISMEMBER', prefix .. ':vendor_gateway:' .. vendor_gateway_id .. ':blocked_city_codes', number_info.city_code)
local info = ensure_number_info(raw_callee)
if info.city_code ~= 'UNKNOWN' then
local city_blocked = redis.call('SISMEMBER', prefix .. ':vendor_gateway:' .. vendor_gateway_id .. ':blocked_city_codes', info.city_code)
if city_blocked == 1 then
return true
end
end
if number_info.province_code ~= 'UNKNOWN' then
local province_blocked = redis.call('SISMEMBER', prefix .. ':vendor_gateway:' .. vendor_gateway_id .. ':blocked_province_codes', number_info.province_code)
if info.province_code ~= 'UNKNOWN' then
local province_blocked = redis.call('SISMEMBER', prefix .. ':vendor_gateway:' .. vendor_gateway_id .. ':blocked_province_codes', info.province_code)
if province_blocked == 1 then
return true
end
@@ -190,7 +262,7 @@ local function region_blocked(vendor_gateway_id)
return false
end
local function first_vendor_route(line_group_id)
local function first_vendor_route(line_group_id, real_callee)
local line_group_json = redis.call('GET', prefix .. ':line_group:' .. line_group_id)
if not line_group_json then
return nil, 'LINE_GROUP_MISSING'
@@ -199,21 +271,27 @@ local function first_vendor_route(line_group_id)
return nil, 'LINE_GROUP_DISABLED'
end
number_info = resolve_number(real_callee)
local normalized_callee = number_info.normalized
local route_callee = normalized_callee ~= '' and normalized_callee or real_callee
local items = redis.call('LRANGE', prefix .. ':line_group:' .. line_group_id .. ':items', 0, -1)
local skipped_region = false
for _, item_json in ipairs(items) do
if string.find(item_json, '"status":"ENABLED"', 1, true) then
local vendor_gateway_id = string.match(item_json, '"vendorGatewayId":"([^"]+)"')
local vendor_gateway_id = field(item_json, 'vendorGatewayId')
if vendor_gateway_id then
local vendor_gateway_json = redis.call('GET', prefix .. ':vendor_gateway:' .. vendor_gateway_id)
if vendor_gateway_json and string.find(vendor_gateway_json, '"status":"ENABLED"', 1, true) then
if region_blocked(vendor_gateway_id) then
skipped_region = true
else
local vendor_id = string.match(vendor_gateway_json, '"vendorId":"([^"]+)"') or 'none'
local host = string.match(vendor_gateway_json, '"host":"([^"]+)"') or 'none'
local port = string.match(vendor_gateway_json, '"port":([0-9]+)') or '5060'
return {vendor_id, vendor_gateway_id, host, port}, 'OK'
local vendor_id = field(vendor_gateway_json, 'vendorId') or 'none'
local host = field(vendor_gateway_json, 'host') or 'none'
local port = number_field(vendor_gateway_json, 'port') or '5060'
local landing_prefix = field(vendor_gateway_json, 'landingCalleePrefix') or ''
local landing_callee = landing_prefix .. route_callee
local rewrite_caller = choose_landing_caller(vendor_gateway_id)
return {vendor_id, vendor_gateway_id, host, port, rewrite_caller, landing_callee}, 'OK'
end
end
end
@@ -226,31 +304,100 @@ local function first_vendor_route(line_group_id)
return nil, 'NO_VENDOR_ROUTE'
end
local policies = redis.call('LRANGE', prefix .. ':customer_gateway:' .. gateway_id .. ':policies', 0, -1)
for _, policy_json in ipairs(policies) do
if string.find(policy_json, '"status":"ENABLED"', 1, true) then
local caller_mode = string.match(policy_json, '"callerMode":"([^"]+)"') or 'ANY'
local caller_value = string.match(policy_json, '"callerValue":"([^"]*)"') or ''
local callee_mode = string.match(policy_json, '"calleeMode":"([^"]+)"') or 'ANY'
local callee_value = string.match(policy_json, '"calleeValue":"([^"]*)"') or ''
local function legacy_policy_route(gateway_id, customer_id)
local policies = redis.call('LRANGE', prefix .. ':customer_gateway:' .. gateway_id .. ':policies', 0, -1)
for _, policy_json in ipairs(policies) do
if string.find(policy_json, '"status":"ENABLED"', 1, true) then
local caller_mode = field(policy_json, 'callerMode') or 'ANY'
local caller_value = field(policy_json, 'callerValue') or ''
local callee_mode = field(policy_json, 'calleeMode') or 'ANY'
local callee_value = field(policy_json, 'calleeValue') or ''
local caller_ok = caller_mode == 'ANY'
or (caller_mode == 'EQUALS' and caller == caller_value)
or (caller_mode == 'PREFIX' and string.sub(caller, 1, string.len(caller_value)) == caller_value)
local callee_ok = callee_mode == 'ANY'
or (callee_mode == 'EQUALS' and callee == callee_value)
or (callee_mode == 'PREFIX' and string.sub(callee, 1, string.len(callee_value)) == callee_value)
local caller_ok = caller_mode == 'ANY'
or (caller_mode == 'EQUALS' and caller == caller_value)
or (caller_mode == 'PREFIX' and starts_with(caller, caller_value))
local callee_ok = callee_mode == 'ANY'
or (callee_mode == 'EQUALS' and raw_callee == callee_value)
or (callee_mode == 'PREFIX' and starts_with(raw_callee, callee_value))
if caller_ok and callee_ok then
local policy_id = string.match(policy_json, '"id":"([^"]+)"') or 'none'
local line_group_id = string.match(policy_json, '"lineGroupId":"([^"]+)"') or 'none'
local vendor_route, route_reason = first_vendor_route(line_group_id)
if vendor_route then
return result('allow', 'OK', gateway_id, active, line_group_id, policy_id, customer_id, vendor_route[1], vendor_route[2], vendor_route[3], vendor_route[4])
if caller_ok and callee_ok then
local policy_id = field(policy_json, 'id') or 'none'
local line_group_id = field(policy_json, 'lineGroupId') or 'none'
local vendor_route, route_reason = first_vendor_route(line_group_id, raw_callee)
if vendor_route then
return result('allow', 'OK', gateway_id, active, line_group_id, policy_id, customer_id, vendor_route[1], vendor_route[2], vendor_route[3], vendor_route[4], raw_callee, 'none', 'none', vendor_route[5], vendor_route[6])
end
return result('reject', route_reason, gateway_id, active, line_group_id, policy_id, customer_id, 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end
return result('reject', route_reason, gateway_id, active, line_group_id, policy_id, customer_id, 'none', 'none', 'none', 'none')
end
end
return nil
end
local candidates = redis.call('LRANGE', prefix .. ':auth:ip:' .. source_ip .. ':gateways', 0, -1)
if #candidates == 0 then
local legacy_gateway_id = redis.call('GET', prefix .. ':auth:ip:' .. source_ip)
if legacy_gateway_id then
candidates = {legacy_gateway_id}
end
end
if #candidates == 0 then
return result('reject', 'AUTH_MISSING', 'none', active, 'none', 'none', 'no_auth_ip', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end
local saw_gateway = false
local saw_customer = false
local saw_caller = false
local saw_business_prefix_required = false
for _, gateway_id in ipairs(candidates) do
local gateway_json = redis.call('GET', prefix .. ':customer_gateway:' .. gateway_id)
if gateway_json and string.find(gateway_json, '"status":"ENABLED"', 1, true) then
saw_gateway = true
local customer_id, customer_reason, customer_marker = customer_ok(gateway_id, gateway_json)
if customer_id then
saw_customer = true
if match_caller(gateway_id, gateway_json) then
saw_caller = true
if (field(gateway_json, 'calleeMatchMode') or 'ANY') == 'BUSINESS_PREFIXES' then
saw_business_prefix_required = true
end
local real_callee, business_prefix_id, business_prefix = match_callee(gateway_id, gateway_json)
if real_callee then
local line_group_id = field(gateway_json, 'lineGroupId')
if line_group_id and line_group_id ~= '' then
local vendor_route, route_reason = first_vendor_route(line_group_id, real_callee)
if vendor_route then
return result('allow', 'OK', gateway_id, active, line_group_id, 'single_gateway', customer_id, vendor_route[1], vendor_route[2], vendor_route[3], vendor_route[4], real_callee, business_prefix_id, business_prefix, vendor_route[5], vendor_route[6])
end
return result('reject', route_reason, gateway_id, active, line_group_id, 'single_gateway', customer_id, 'none', 'none', 'none', 'none', real_callee, business_prefix_id, business_prefix, caller, real_callee)
end
local legacy = legacy_policy_route(gateway_id, customer_id)
if legacy then
return legacy
end
return result('reject', 'LINE_GROUP_MISSING', gateway_id, active, 'none', 'single_gateway', customer_id, 'none', 'none', 'none', 'none', real_callee, business_prefix_id, business_prefix, caller, real_callee)
end
end
else
return result('reject', customer_reason, gateway_id, active, 'none', 'none', customer_marker, 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end
end
end
return result('reject', 'NO_POLICY', gateway_id, active, 'none', 'none', 'no_policy_match', 'none', 'none', 'none', 'none')
if not saw_gateway then
return result('reject', 'GATEWAY_MISSING', candidates[1] or 'none', active, 'none', 'none', 'gateway_missing', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end
if not saw_customer then
return result('reject', 'CUSTOMER_MISSING', candidates[1] or 'none', active, 'none', 'none', 'customer_missing', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end
if not saw_caller then
return result('reject', 'CALLER_PREFIX_NOT_MATCHED', candidates[1] or 'none', active, 'none', 'none', 'caller_prefix_not_matched', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end
if saw_business_prefix_required then
return result('reject', 'BUSINESS_PREFIX_NOT_MATCHED', candidates[1] or 'none', active, 'none', 'none', 'business_prefix_not_matched', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end
return result('reject', 'NO_POLICY', candidates[1] or 'none', active, 'none', 'none', 'no_gateway_match', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
+37 -7
View File
@@ -46,6 +46,8 @@ loadmodule "auth.so"
modparam("auth", "nonce_expire", 300)
modparam("auth", "disable_nonce_check", 0)
loadmodule "uac.so"
loadmodule "pike.so"
modparam("pike", "sampling_time_unit", 2)
modparam("pike", "reqs_density_per_unit", 32)
@@ -182,11 +184,17 @@ route[S28_INVITE] {
$var(s28_callee_operator) = "UNKNOWN";
$var(s28_callee_number_type) = "UNKNOWN";
$var(s28_callee_province_code) = "UNKNOWN";
$var(s28_callee_normalized) = "";
$var(s28_real_callee) = $rU;
$var(s28_raw_callee) = $rU;
$var(s28_business_prefix_id) = "none";
$var(s28_business_prefix) = "none";
$var(s28_landing_caller) = $fU;
$var(s28_landing_callee) = $rU;
$var(s28_callee_normalized) = $rU;
$var(s28_reply_code) = 503;
$var(s28_reply_text) = "Routing Not Ready";
if (!cache_raw_query("redis:s20", "EVALSHA 229c06326b97d3d64b0b02deedec77a764a61045 1 cfg:active_version $si $fU $rU $ci", "$avp(s28_hotpath)")) {
if (!cache_raw_query("redis:s20", "EVALSHA 0b7ee595cdcad2f38a670dd8ff3553387820039e 1 cfg:active_version $si $fU $rU $ci", "$avp(s28_hotpath)")) {
xlog("L_ERR", "S28 Redis hotpath unavailable source=$si callid=$ci\n");
$var(s28_reason) = "REDIS_UNAVAILABLE";
$var(s28_reply_code) = 503;
@@ -213,7 +221,13 @@ route[S28_INVITE] {
$var(s28_callee_operator) = $(avp(s28_hotpath)[14]);
$var(s28_callee_number_type) = $(avp(s28_hotpath)[15]);
$var(s28_callee_province_code) = $(avp(s28_hotpath)[16]);
$var(s28_callee_normalized) = $(avp(s28_hotpath)[17]);
$var(s28_real_callee) = $(avp(s28_hotpath)[17]);
$var(s28_raw_callee) = $(avp(s28_hotpath)[18]);
$var(s28_business_prefix_id) = $(avp(s28_hotpath)[19]);
$var(s28_business_prefix) = $(avp(s28_hotpath)[20]);
$var(s28_landing_caller) = $(avp(s28_hotpath)[21]);
$var(s28_landing_callee) = $(avp(s28_hotpath)[22]);
$var(s28_callee_normalized) = $(avp(s28_hotpath)[23]);
if ($var(s28_decision) != "allow") {
update_stat("s28_hotpath_reject_total", 1);
@@ -223,6 +237,12 @@ route[S28_INVITE] {
} else if ($var(s28_reason) == "NO_POLICY" || $var(s28_reason) == "NO_VENDOR_ROUTE" || $var(s28_reason) == "NO_VENDOR_ROUTE_REGION_BLOCKED" || $var(s28_reason) == "LINE_GROUP_MISSING" || $var(s28_reason) == "LINE_GROUP_DISABLED") {
$var(s28_reply_code) = 503;
$var(s28_reply_text) = "No Route Policy";
} else if ($var(s28_reason) == "BUSINESS_PREFIX_NOT_MATCHED") {
$var(s28_reply_code) = 404;
$var(s28_reply_text) = "Business Prefix Not Matched";
} else if ($var(s28_reason) == "CALLER_PREFIX_NOT_MATCHED") {
$var(s28_reply_code) = 403;
$var(s28_reply_text) = "Caller Prefix Not Matched";
} else {
$var(s28_reply_code) = 403;
$var(s28_reply_text) = "Forbidden";
@@ -251,7 +271,13 @@ route[S28_INVITE] {
$dlg_val(s28_vendor_id) = $var(s28_vendor_id);
$dlg_val(s28_vendor_gateway_id) = $var(s28_vendor_gateway_id);
$dlg_val(s28_config_version) = $var(s28_config_version);
$dlg_val(s28_callee) = $rU;
$dlg_val(s28_callee) = $var(s28_real_callee);
$dlg_val(s28_raw_callee) = $var(s28_raw_callee);
$dlg_val(s28_business_prefix_id) = $var(s28_business_prefix_id);
$dlg_val(s28_business_prefix) = $var(s28_business_prefix);
$dlg_val(s28_landing_caller) = $var(s28_landing_caller);
$dlg_val(s28_landing_callee) = $var(s28_landing_callee);
$dlg_val(s28_callee_normalized) = $var(s28_callee_normalized);
$dlg_val(s28_callee_city_code) = $var(s28_callee_city_code);
$dlg_val(s28_callee_city_name) = $var(s28_callee_city_name);
$dlg_val(s28_callee_province_name) = $var(s28_callee_province_name);
@@ -272,7 +298,11 @@ route[S28_INVITE] {
record_route();
$du = "sip:" + $var(s28_vendor_host) + ":" + $var(s28_vendor_port);
$ru = "sip:" + $rU + "@" + $var(s28_vendor_host) + ":" + $var(s28_vendor_port);
$var(s28_landing_from_uri) = "sip:" + $var(s28_landing_caller) + "@" + $var(s28_vendor_host);
if ($var(s28_landing_caller) != $fU) {
uac_replace_from("", "$var(s28_landing_from_uri)");
}
$ru = "sip:" + $var(s28_landing_callee) + "@" + $var(s28_vendor_host) + ":" + $var(s28_vendor_port);
t_on_reply("S28_REPLY");
if (!t_relay()) {
@@ -297,7 +327,7 @@ onreply_route[S28_REPLY] {
route[S28_CDR_SUCCESS] {
$var(s28_event_id) = "s28-ok-" + $Ts + "-" + $pp + "-" + $ci;
$var(s28_idempotency_key) = $ci + ":s28-ok:" + $Ts;
if (cache_raw_query("redis:s20", "XADD stream:cdr_payload * schema_version 1 event_id $var(s28_event_id) idempotency_key $var(s28_idempotency_key) call_id $ci node_id a1 opensips_instance opensips-a1 ingress_a_ip 100.90.90.90 rtpengine_node a1 source_ip $si caller $fU callee $dlg_val(s28_callee) callee_city_code $dlg_val(s28_callee_city_code) callee_city_name $dlg_val(s28_callee_city_name) callee_province_name $dlg_val(s28_callee_province_name) callee_operator $dlg_val(s28_callee_operator) callee_number_type $dlg_val(s28_callee_number_type) customer_id $dlg_val(s28_customer_id) customer_gateway_id $dlg_val(s28_gateway_id) customer_gateway_policy_id $dlg_val(s28_policy_id) vendor_id $dlg_val(s28_vendor_id) vendor_gateway_id $dlg_val(s28_vendor_gateway_id) line_group_id $dlg_val(s28_line_group_id) started_at $Ts answered_at $Ts ended_at $Ts duration_sec 6 sip_code 200 hangup_reason NORMAL_CLEARING recording_key none config_version $dlg_val(s28_config_version) created_at $Ts", "$avp(s28_cdr_id)")) {
if (cache_raw_query("redis:s20", "XADD stream:cdr_payload * schema_version 1 event_id $var(s28_event_id) idempotency_key $var(s28_idempotency_key) call_id $ci node_id a1 opensips_instance opensips-a1 ingress_a_ip 100.90.90.90 rtpengine_node a1 source_ip $si caller $fU callee $dlg_val(s28_callee) raw_callee $dlg_val(s28_raw_callee) business_prefix_id $dlg_val(s28_business_prefix_id) business_prefix $dlg_val(s28_business_prefix) landing_caller $dlg_val(s28_landing_caller) landing_callee $dlg_val(s28_landing_callee) normalized_callee $dlg_val(s28_callee_normalized) callee_city_code $dlg_val(s28_callee_city_code) callee_city_name $dlg_val(s28_callee_city_name) callee_province_name $dlg_val(s28_callee_province_name) callee_operator $dlg_val(s28_callee_operator) callee_number_type $dlg_val(s28_callee_number_type) customer_id $dlg_val(s28_customer_id) customer_gateway_id $dlg_val(s28_gateway_id) customer_gateway_policy_id $dlg_val(s28_policy_id) vendor_id $dlg_val(s28_vendor_id) vendor_gateway_id $dlg_val(s28_vendor_gateway_id) line_group_id $dlg_val(s28_line_group_id) started_at $Ts answered_at $Ts ended_at $Ts duration_sec 6 sip_code 200 hangup_reason NORMAL_CLEARING recording_key none config_version $dlg_val(s28_config_version) created_at $Ts", "$avp(s28_cdr_id)")) {
update_stat("s28_cdr_xadd_total", 1);
xlog("L_INFO", "S28 success CDR XADD ok redis_id=$avp(s28_cdr_id) event_id=$var(s28_event_id) callid=$ci\n");
} else {
@@ -309,7 +339,7 @@ route[S28_CDR_SUCCESS] {
route[S28_CDR_FAILURE] {
$var(s28_event_id) = "s28-fail-" + $Ts + "-" + $pp + "-" + $ci;
$var(s28_idempotency_key) = $ci + ":s28-fail:" + $Ts;
if (cache_raw_query("redis:s20", "XADD stream:cdr_payload * schema_version 1 event_id $var(s28_event_id) idempotency_key $var(s28_idempotency_key) call_id $ci node_id a1 opensips_instance opensips-a1 ingress_a_ip 100.90.90.90 rtpengine_node a1 source_ip $si caller $fU callee $rU callee_city_code $var(s28_callee_city_code) callee_city_name $var(s28_callee_city_name) callee_province_name $var(s28_callee_province_name) callee_operator $var(s28_callee_operator) callee_number_type $var(s28_callee_number_type) customer_id $var(s28_customer_id) customer_gateway_id $var(s28_gateway_id) customer_gateway_policy_id $var(s28_policy_id) vendor_id none vendor_gateway_id none line_group_id $var(s28_line_group_id) started_at $Ts answered_at none ended_at $Ts duration_sec 0 sip_code $var(s28_reply_code) hangup_reason $var(s28_reason) recording_key none config_version $var(s28_config_version) created_at $Ts", "$avp(s28_cdr_id)")) {
if (cache_raw_query("redis:s20", "XADD stream:cdr_payload * schema_version 1 event_id $var(s28_event_id) idempotency_key $var(s28_idempotency_key) call_id $ci node_id a1 opensips_instance opensips-a1 ingress_a_ip 100.90.90.90 rtpengine_node a1 source_ip $si caller $fU callee $var(s28_real_callee) raw_callee $var(s28_raw_callee) business_prefix_id $var(s28_business_prefix_id) business_prefix $var(s28_business_prefix) landing_caller $var(s28_landing_caller) landing_callee $var(s28_landing_callee) normalized_callee $var(s28_callee_normalized) callee_city_code $var(s28_callee_city_code) callee_city_name $var(s28_callee_city_name) callee_province_name $var(s28_callee_province_name) callee_operator $var(s28_callee_operator) callee_number_type $var(s28_callee_number_type) customer_id $var(s28_customer_id) customer_gateway_id $var(s28_gateway_id) customer_gateway_policy_id $var(s28_policy_id) vendor_id none vendor_gateway_id none line_group_id $var(s28_line_group_id) started_at $Ts answered_at none ended_at $Ts duration_sec 0 sip_code $var(s28_reply_code) hangup_reason $var(s28_reason) recording_key none config_version $var(s28_config_version) created_at $Ts", "$avp(s28_cdr_id)")) {
update_stat("s28_cdr_xadd_total", 1);
} else {
update_stat("s28_cdr_xadd_error_total", 1);
+27 -1
View File
@@ -6,12 +6,20 @@ const schema = readFileSync(join(process.cwd(), 'prisma/schema.prisma'), 'utf8')
const migration = readFileSync(join(process.cwd(), 'prisma/migrations/20260621090000_init_v2_schema/migration.sql'), 'utf8');
const authMigration = readFileSync(join(process.cwd(), 'prisma/migrations/20260621093000_auth_sessions/migration.sql'), 'utf8');
const numberLibraryMigration = readFileSync(join(process.cwd(), 'prisma/migrations/20260624095000_number_library/migration.sql'), 'utf8');
const businessPrefixMigration = readFileSync(
join(process.cwd(), 'prisma/migrations/20260624121000_business_prefix_gateway_rewrite/migration.sql'),
'utf8'
);
describe('Prisma schema contract', () => {
it('defines every V2 core business table', () => {
const tables = [
'customers',
'customer_gateways',
'customer_gateway_ips',
'business_prefixes',
'customer_gateway_business_prefixes',
'customer_gateway_caller_prefixes',
'customer_gateway_policies',
'customer_recharges',
'vendors',
@@ -21,6 +29,7 @@ describe('Prisma schema contract', () => {
'vendor_gateway_forbidden_periods',
'vendor_gateway_codecs',
'vendor_gateway_prefix_rules',
'vendor_gateway_caller_rewrite_pool',
'landing_line_groups',
'landing_line_group_items',
'geo_cities',
@@ -45,7 +54,9 @@ describe('Prisma schema contract', () => {
for (const table of tables) {
expect(schema).toContain(`@@map("${table}")`);
expect(`${migration}\n${authMigration}\n${numberLibraryMigration}`).toContain(`CREATE TABLE \`${table}\``);
expect(`${migration}\n${authMigration}\n${numberLibraryMigration}\n${businessPrefixMigration}`).toContain(
`CREATE TABLE \`${table}\``
);
}
});
@@ -68,6 +79,21 @@ describe('Prisma schema contract', () => {
expect(numberLibraryMigration).toContain('CREATE INDEX `raw_cdrs_callee_operator_started_at_idx`');
});
it('models phase-2 business prefix gateway matching and landing rewrites', () => {
expect(schema).toContain('model BusinessPrefix');
expect(schema).toContain('model CustomerGatewayIp');
expect(schema).toContain('model CustomerGatewayBusinessPrefix');
expect(schema).toContain('model CustomerGatewayCallerPrefix');
expect(schema).toContain('model VendorGatewayCallerRewrite');
expect(schema).toContain('enum CustomerGatewayCallerMatchMode');
expect(schema).toContain('enum CustomerGatewayCalleeMatchMode');
expect(businessPrefixMigration).toContain('`billing_cycle_sec` INTEGER NOT NULL DEFAULT 60');
expect(businessPrefixMigration).toContain('`landing_callee_prefix` VARCHAR(64) NULL');
expect(businessPrefixMigration).toContain('`raw_callee` VARCHAR(64) NULL');
expect(businessPrefixMigration).toContain('`landing_caller` VARCHAR(64) NULL');
expect(businessPrefixMigration).toContain('CREATE INDEX `raw_cdrs_business_prefix_id_started_at_idx`');
});
it('models audit columns and outbox publishing state', () => {
expect(schema).toContain('createdAt');
expect(schema).toContain('updatedAt');
+19
View File
@@ -142,6 +142,12 @@ function sampleEvent(overrides: Partial<CdrStreamPublishInput> = {}): CdrStreamP
sourceIp: '100.93.185.30',
caller: 's21-ip-1001',
callee: '13800138000',
rawCallee: '67113800138000',
businessPrefixId: 'bp_1',
businessPrefix: '671',
landingCaller: '02160010001',
landingCallee: '8613800138000',
normalizedCallee: '13800138000',
calleeCityCode: '340100',
calleeCityName: '合肥市',
calleeProvinceName: '安徽省',
@@ -167,6 +173,13 @@ describe('CDR Redis Stream contract', () => {
expect(parsed.node_id).toBe('a1');
expect(parsed.opensips_instance).toBe('opensips-a1');
expect(parsed.rtpengine_node).toBe('a1');
expect(parsed.callee).toBe('13800138000');
expect(parsed.raw_callee).toBe('67113800138000');
expect(parsed.business_prefix_id).toBe('bp_1');
expect(parsed.business_prefix).toBe('671');
expect(parsed.landing_caller).toBe('02160010001');
expect(parsed.landing_callee).toBe('8613800138000');
expect(parsed.normalized_callee).toBe('13800138000');
expect(parsed.callee_city_code).toBe('340100');
expect(parsed.callee_city_name).toBe('合肥市');
expect(parsed.callee_operator).toBe('MOBILE');
@@ -202,6 +215,12 @@ describe('CDR Redis Stream contract', () => {
]);
expect(parsed.callee_city_code).toBe('');
expect(parsed.raw_callee).toBe('');
expect(parsed.business_prefix_id).toBe('');
expect(parsed.business_prefix).toBe('');
expect(parsed.landing_caller).toBe('');
expect(parsed.landing_callee).toBe('');
expect(parsed.normalized_callee).toBe('');
expect(parsed.callee_operator).toBe('UNKNOWN');
expect(parsed.callee_number_type).toBe('UNKNOWN');
});
+24
View File
@@ -36,6 +36,12 @@ export interface CdrStreamEvent {
source_ip: string;
caller: string;
callee: string;
raw_callee: string;
business_prefix_id: string;
business_prefix: string;
landing_caller: string;
landing_callee: string;
normalized_callee: string;
callee_city_code: string;
callee_city_name: string;
callee_province_name: string;
@@ -74,6 +80,12 @@ export interface CdrStreamPublishInput {
sourceIp: string;
caller?: string;
callee?: string;
rawCallee?: string;
businessPrefixId?: string;
businessPrefix?: string;
landingCaller?: string;
landingCallee?: string;
normalizedCallee?: string;
calleeCityCode?: string;
calleeCityName?: string;
calleeProvinceName?: string;
@@ -164,6 +176,12 @@ export function buildCdrStreamEvent(input: CdrStreamPublishInput): CdrStreamEven
source_ip: input.sourceIp,
caller: input.caller ?? '',
callee: input.callee ?? '',
raw_callee: input.rawCallee ?? input.callee ?? '',
business_prefix_id: input.businessPrefixId ?? '',
business_prefix: input.businessPrefix ?? '',
landing_caller: input.landingCaller ?? input.caller ?? '',
landing_callee: input.landingCallee ?? input.callee ?? '',
normalized_callee: input.normalizedCallee ?? input.callee ?? '',
callee_city_code: input.calleeCityCode ?? '',
callee_city_name: input.calleeCityName ?? '',
callee_province_name: input.calleeProvinceName ?? '',
@@ -235,6 +253,12 @@ export function parseCdrStreamEvent(fields: string[]): ParsedCdrStreamEvent {
source_ip: parsed.source_ip,
caller: parsed.caller ?? '',
callee: parsed.callee ?? '',
raw_callee: parsed.raw_callee ?? '',
business_prefix_id: parsed.business_prefix_id ?? '',
business_prefix: parsed.business_prefix ?? '',
landing_caller: parsed.landing_caller ?? '',
landing_callee: parsed.landing_callee ?? '',
normalized_callee: parsed.normalized_callee ?? '',
callee_city_code: parsed.callee_city_code ?? '',
callee_city_name: parsed.callee_city_name ?? '',
callee_province_name: parsed.callee_province_name ?? '',
@@ -108,7 +108,7 @@ CREATE TABLE `vendor_gateway_blocked_regions` (
INDEX `vendor_gateway_blocked_regions_vendor_gateway_id_idx`(`vendor_gateway_id`),
INDEX `vendor_gateway_blocked_regions_province_code_idx`(`province_code`),
INDEX `vendor_gateway_blocked_regions_city_code_idx`(`city_code`),
UNIQUE INDEX `vendor_gateway_blocked_regions_vendor_gateway_id_region_scope_province_code_city_code_key`(`vendor_gateway_id`, `region_scope`, `province_code`, `city_code`),
UNIQUE INDEX `vgb_regions_gateway_scope_region_key`(`vendor_gateway_id`, `region_scope`, `province_code`, `city_code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,167 @@
-- V2 phase-2 gateway matching and landing rewrite expansion.
-- This migration is additive and keeps legacy customer_gateway_policies and
-- vendor_gateway_prefix_rules in place for staged API/OpenSIPS rollout.
CREATE TABLE `business_prefixes` (
`id` VARCHAR(32) NOT NULL,
`prefix` VARCHAR(32) NOT NULL,
`name` VARCHAR(120) NOT NULL,
`description` VARCHAR(500) NULL,
`priority` INTEGER NOT NULL DEFAULT 100,
`status` ENUM('ENABLED', 'DISABLED') NOT NULL DEFAULT 'ENABLED',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
`created_by` VARCHAR(32) NULL,
`updated_by` VARCHAR(32) NULL,
`version` INTEGER NOT NULL DEFAULT 1,
`deleted_at` DATETIME(3) NULL,
UNIQUE INDEX `business_prefixes_prefix_key`(`prefix`),
INDEX `business_prefixes_status_priority_idx`(`status`, `priority`),
INDEX `business_prefixes_deleted_at_idx`(`deleted_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `customer_gateways`
ADD COLUMN `line_group_id` VARCHAR(32) NULL,
ADD COLUMN `billing_cycle_sec` INTEGER NOT NULL DEFAULT 60,
ADD COLUMN `cycle_rate` DECIMAL(20, 6) NOT NULL DEFAULT 0,
ADD COLUMN `caller_match_mode` ENUM('ANY', 'PREFIXES') NOT NULL DEFAULT 'ANY',
ADD COLUMN `callee_match_mode` ENUM('ANY', 'BUSINESS_PREFIXES') NOT NULL DEFAULT 'ANY';
CREATE INDEX `customer_gateways_line_group_id_idx` ON `customer_gateways`(`line_group_id`);
ALTER TABLE `customer_gateways`
ADD CONSTRAINT `customer_gateways_line_group_id_fkey`
FOREIGN KEY (`line_group_id`) REFERENCES `landing_line_groups`(`id`)
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE TABLE `customer_gateway_ips` (
`id` VARCHAR(32) NOT NULL,
`gateway_id` VARCHAR(32) NOT NULL,
`source_ip` VARCHAR(45) NOT NULL,
`status` ENUM('ENABLED', 'DISABLED') NOT NULL DEFAULT 'ENABLED',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
`created_by` VARCHAR(32) NULL,
`updated_by` VARCHAR(32) NULL,
`version` INTEGER NOT NULL DEFAULT 1,
`deleted_at` DATETIME(3) NULL,
UNIQUE INDEX `customer_gateway_ips_gateway_id_source_ip_key`(`gateway_id`, `source_ip`),
INDEX `customer_gateway_ips_source_ip_status_idx`(`source_ip`, `status`),
INDEX `customer_gateway_ips_deleted_at_idx`(`deleted_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `customer_gateway_ips`
ADD CONSTRAINT `customer_gateway_ips_gateway_id_fkey`
FOREIGN KEY (`gateway_id`) REFERENCES `customer_gateways`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
INSERT INTO `customer_gateway_ips` (
`id`,
`gateway_id`,
`source_ip`,
`status`,
`created_at`,
`updated_at`,
`created_by`,
`updated_by`,
`version`,
`deleted_at`
)
SELECT
CONCAT('cgip_', LEFT(REPLACE(UUID(), '-', ''), 27)),
`id`,
`source_ip`,
`status`,
`created_at`,
`updated_at`,
`created_by`,
`updated_by`,
`version`,
`deleted_at`
FROM `customer_gateways`
WHERE `source_ip` IS NOT NULL
AND `source_ip` <> '';
CREATE TABLE `customer_gateway_business_prefixes` (
`id` VARCHAR(32) NOT NULL,
`gateway_id` VARCHAR(32) NOT NULL,
`business_prefix_id` VARCHAR(32) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_by` VARCHAR(32) NULL,
UNIQUE INDEX `cgbp_gateway_business_prefix_key`(`gateway_id`, `business_prefix_id`),
INDEX `customer_gateway_business_prefixes_business_prefix_id_idx`(`business_prefix_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `customer_gateway_business_prefixes`
ADD CONSTRAINT `customer_gateway_business_prefixes_gateway_id_fkey`
FOREIGN KEY (`gateway_id`) REFERENCES `customer_gateways`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `customer_gateway_business_prefixes`
ADD CONSTRAINT `customer_gateway_business_prefixes_business_prefix_id_fkey`
FOREIGN KEY (`business_prefix_id`) REFERENCES `business_prefixes`(`id`)
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE TABLE `customer_gateway_caller_prefixes` (
`id` VARCHAR(32) NOT NULL,
`gateway_id` VARCHAR(32) NOT NULL,
`prefix` VARCHAR(64) NOT NULL,
`priority` INTEGER NOT NULL DEFAULT 100,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_by` VARCHAR(32) NULL,
UNIQUE INDEX `customer_gateway_caller_prefixes_gateway_id_prefix_key`(`gateway_id`, `prefix`),
INDEX `customer_gateway_caller_prefixes_gateway_id_priority_idx`(`gateway_id`, `priority`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `customer_gateway_caller_prefixes`
ADD CONSTRAINT `customer_gateway_caller_prefixes_gateway_id_fkey`
FOREIGN KEY (`gateway_id`) REFERENCES `customer_gateways`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `vendor_gateways`
ADD COLUMN `landing_callee_prefix` VARCHAR(64) NULL;
CREATE TABLE `vendor_gateway_caller_rewrite_pool` (
`id` VARCHAR(32) NOT NULL,
`vendor_gateway_id` VARCHAR(32) NOT NULL,
`caller` VARCHAR(64) NOT NULL,
`weight` INTEGER NOT NULL DEFAULT 1,
`status` ENUM('ENABLED', 'DISABLED') NOT NULL DEFAULT 'ENABLED',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
`created_by` VARCHAR(32) NULL,
`updated_by` VARCHAR(32) NULL,
`version` INTEGER NOT NULL DEFAULT 1,
`deleted_at` DATETIME(3) NULL,
INDEX `vendor_gateway_caller_rewrite_pool_vendor_gateway_id_status_idx`(`vendor_gateway_id`, `status`),
INDEX `vendor_gateway_caller_rewrite_pool_deleted_at_idx`(`deleted_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `vendor_gateway_caller_rewrite_pool`
ADD CONSTRAINT `vendor_gateway_caller_rewrite_pool_vendor_gateway_id_fkey`
FOREIGN KEY (`vendor_gateway_id`) REFERENCES `vendor_gateways`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `raw_cdrs`
ADD COLUMN `raw_callee` VARCHAR(64) NULL,
ADD COLUMN `business_prefix_id` VARCHAR(32) NULL,
ADD COLUMN `business_prefix` VARCHAR(32) NULL,
ADD COLUMN `landing_caller` VARCHAR(64) NULL,
ADD COLUMN `landing_callee` VARCHAR(64) NULL;
CREATE INDEX `raw_cdrs_business_prefix_id_started_at_idx` ON `raw_cdrs`(`business_prefix_id`, `started_at`);
ALTER TABLE `raw_cdrs`
ADD CONSTRAINT `raw_cdrs_business_prefix_id_fkey`
FOREIGN KEY (`business_prefix_id`) REFERENCES `business_prefixes`(`id`)
ON DELETE SET NULL ON UPDATE CASCADE;
+171 -45
View File
@@ -37,6 +37,20 @@ enum MatchMode {
@@map("match_mode")
}
enum CustomerGatewayCallerMatchMode {
ANY
PREFIXES
@@map("customer_gateway_caller_match_mode")
}
enum CustomerGatewayCalleeMatchMode {
ANY
BUSINESS_PREFIXES
@@map("customer_gateway_callee_match_mode")
}
enum RechargeStatus {
SUCCEEDED
FAILED
@@ -162,34 +176,116 @@ model Customer {
}
model CustomerGateway {
id String @id @db.VarChar(32)
customerId String @map("customer_id") @db.VarChar(32)
name String @db.VarChar(120)
authMode GatewayAuthMode @map("auth_mode")
sourceIp String? @map("source_ip") @db.VarChar(45)
sipUsername String? @map("sip_username") @db.VarChar(120)
sipDomain String? @map("sip_domain") @db.VarChar(160)
sipHa1 String? @map("sip_ha1") @db.VarChar(128)
status EntityStatus @default(ENABLED)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdBy String? @map("created_by") @db.VarChar(32)
updatedBy String? @map("updated_by") @db.VarChar(32)
version Int @default(1)
deletedAt DateTime? @map("deleted_at") @db.DateTime(3)
id String @id @db.VarChar(32)
customerId String @map("customer_id") @db.VarChar(32)
name String @db.VarChar(120)
authMode GatewayAuthMode @map("auth_mode")
sourceIp String? @map("source_ip") @db.VarChar(45)
sipUsername String? @map("sip_username") @db.VarChar(120)
sipDomain String? @map("sip_domain") @db.VarChar(160)
sipHa1 String? @map("sip_ha1") @db.VarChar(128)
lineGroupId String? @map("line_group_id") @db.VarChar(32)
billingCycleSec Int @default(60) @map("billing_cycle_sec")
cycleRate Decimal @default(0) @map("cycle_rate") @db.Decimal(20, 6)
callerMatchMode CustomerGatewayCallerMatchMode @default(ANY) @map("caller_match_mode")
calleeMatchMode CustomerGatewayCalleeMatchMode @default(ANY) @map("callee_match_mode")
status EntityStatus @default(ENABLED)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdBy String? @map("created_by") @db.VarChar(32)
updatedBy String? @map("updated_by") @db.VarChar(32)
version Int @default(1)
deletedAt DateTime? @map("deleted_at") @db.DateTime(3)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
policies CustomerGatewayPolicy[]
rawCdrs RawCdr[]
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
lineGroup LandingLineGroup? @relation(fields: [lineGroupId], references: [id], onDelete: Restrict)
ips CustomerGatewayIp[]
businessPrefixes CustomerGatewayBusinessPrefix[]
callerPrefixes CustomerGatewayCallerPrefix[]
policies CustomerGatewayPolicy[]
rawCdrs RawCdr[]
@@unique([customerId, name])
@@unique([sipUsername, sipDomain])
@@index([customerId, status])
@@index([lineGroupId])
@@index([sourceIp])
@@index([deletedAt])
@@map("customer_gateways")
}
model BusinessPrefix {
id String @id @db.VarChar(32)
prefix String @unique @db.VarChar(32)
name String @db.VarChar(120)
description String? @db.VarChar(500)
priority Int @default(100)
status EntityStatus @default(ENABLED)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdBy String? @map("created_by") @db.VarChar(32)
updatedBy String? @map("updated_by") @db.VarChar(32)
version Int @default(1)
deletedAt DateTime? @map("deleted_at") @db.DateTime(3)
customerGateways CustomerGatewayBusinessPrefix[]
rawCdrs RawCdr[]
@@index([status, priority])
@@index([deletedAt])
@@map("business_prefixes")
}
model CustomerGatewayIp {
id String @id @db.VarChar(32)
gatewayId String @map("gateway_id") @db.VarChar(32)
sourceIp String @map("source_ip") @db.VarChar(45)
status EntityStatus @default(ENABLED)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdBy String? @map("created_by") @db.VarChar(32)
updatedBy String? @map("updated_by") @db.VarChar(32)
version Int @default(1)
deletedAt DateTime? @map("deleted_at") @db.DateTime(3)
gateway CustomerGateway @relation(fields: [gatewayId], references: [id], onDelete: Cascade)
@@unique([gatewayId, sourceIp])
@@index([sourceIp, status])
@@index([deletedAt])
@@map("customer_gateway_ips")
}
model CustomerGatewayBusinessPrefix {
id String @id @db.VarChar(32)
gatewayId String @map("gateway_id") @db.VarChar(32)
businessPrefixId String @map("business_prefix_id") @db.VarChar(32)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
createdBy String? @map("created_by") @db.VarChar(32)
gateway CustomerGateway @relation(fields: [gatewayId], references: [id], onDelete: Cascade)
businessPrefix BusinessPrefix @relation(fields: [businessPrefixId], references: [id], onDelete: Restrict)
@@unique([gatewayId, businessPrefixId])
@@index([businessPrefixId])
@@map("customer_gateway_business_prefixes")
}
model CustomerGatewayCallerPrefix {
id String @id @db.VarChar(32)
gatewayId String @map("gateway_id") @db.VarChar(32)
prefix String @db.VarChar(64)
priority Int @default(100)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
createdBy String? @map("created_by") @db.VarChar(32)
gateway CustomerGateway @relation(fields: [gatewayId], references: [id], onDelete: Cascade)
@@unique([gatewayId, prefix])
@@index([gatewayId, priority])
@@map("customer_gateway_caller_prefixes")
}
model CustomerGatewayPolicy {
id String @id @db.VarChar(32)
customerId String @map("customer_id") @db.VarChar(32)
@@ -287,34 +383,36 @@ model VendorRecharge {
}
model VendorGateway {
id String @id @db.VarChar(32)
vendorId String @map("vendor_id") @db.VarChar(32)
name String @db.VarChar(120)
authMode GatewayAuthMode @map("auth_mode")
host String @db.VarChar(160)
port Int @default(5060)
transport String @default("udp") @db.VarChar(16)
sipUsername String? @map("sip_username") @db.VarChar(120)
sipHa1 String? @map("sip_ha1") @db.VarChar(128)
cpsLimit Int @default(0) @map("cps_limit")
concurrencyLimit Int @default(0) @map("concurrency_limit")
billingCycleSec Int @default(60) @map("billing_cycle_sec")
cycleRate Decimal @default(0) @map("cycle_rate") @db.Decimal(20, 6)
status EntityStatus @default(ENABLED)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdBy String? @map("created_by") @db.VarChar(32)
updatedBy String? @map("updated_by") @db.VarChar(32)
version Int @default(1)
deletedAt DateTime? @map("deleted_at") @db.DateTime(3)
id String @id @db.VarChar(32)
vendorId String @map("vendor_id") @db.VarChar(32)
name String @db.VarChar(120)
authMode GatewayAuthMode @map("auth_mode")
host String @db.VarChar(160)
port Int @default(5060)
transport String @default("udp") @db.VarChar(16)
sipUsername String? @map("sip_username") @db.VarChar(120)
sipHa1 String? @map("sip_ha1") @db.VarChar(128)
cpsLimit Int @default(0) @map("cps_limit")
concurrencyLimit Int @default(0) @map("concurrency_limit")
billingCycleSec Int @default(60) @map("billing_cycle_sec")
cycleRate Decimal @default(0) @map("cycle_rate") @db.Decimal(20, 6)
landingCalleePrefix String? @map("landing_callee_prefix") @db.VarChar(64)
status EntityStatus @default(ENABLED)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdBy String? @map("created_by") @db.VarChar(32)
updatedBy String? @map("updated_by") @db.VarChar(32)
version Int @default(1)
deletedAt DateTime? @map("deleted_at") @db.DateTime(3)
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
forbiddenPeriods VendorGatewayForbiddenPeriod[]
blockedRegions VendorGatewayBlockedRegion[]
codecs VendorGatewayCodec[]
prefixRules VendorGatewayPrefixRule[]
lineGroupItems LandingLineGroupItem[]
rawCdrs RawCdr[]
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
forbiddenPeriods VendorGatewayForbiddenPeriod[]
blockedRegions VendorGatewayBlockedRegion[]
codecs VendorGatewayCodec[]
prefixRules VendorGatewayPrefixRule[]
callerRewritePool VendorGatewayCallerRewrite[]
lineGroupItems LandingLineGroupItem[]
rawCdrs RawCdr[]
@@unique([vendorId, name])
@@index([vendorId, status])
@@ -498,6 +596,26 @@ model VendorGatewayPrefixRule {
@@map("vendor_gateway_prefix_rules")
}
model VendorGatewayCallerRewrite {
id String @id @db.VarChar(32)
vendorGatewayId String @map("vendor_gateway_id") @db.VarChar(32)
caller String @db.VarChar(64)
weight Int @default(1)
status EntityStatus @default(ENABLED)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdBy String? @map("created_by") @db.VarChar(32)
updatedBy String? @map("updated_by") @db.VarChar(32)
version Int @default(1)
deletedAt DateTime? @map("deleted_at") @db.DateTime(3)
vendorGateway VendorGateway @relation(fields: [vendorGatewayId], references: [id], onDelete: Cascade)
@@index([vendorGatewayId, status])
@@index([deletedAt])
@@map("vendor_gateway_caller_rewrite_pool")
}
model LandingLineGroup {
id String @id @db.VarChar(32)
name String @unique @db.VarChar(120)
@@ -511,6 +629,7 @@ model LandingLineGroup {
deletedAt DateTime? @map("deleted_at") @db.DateTime(3)
items LandingLineGroupItem[]
gateways CustomerGateway[]
policies CustomerGatewayPolicy[]
rawCdrs RawCdr[]
rules QualitySamplingRule[]
@@ -553,6 +672,9 @@ model RawCdr {
sourceIp String? @map("source_ip") @db.VarChar(45)
caller String @db.VarChar(64)
callee String @db.VarChar(64)
rawCallee String? @map("raw_callee") @db.VarChar(64)
businessPrefixId String? @map("business_prefix_id") @db.VarChar(32)
businessPrefix String? @map("business_prefix") @db.VarChar(32)
calleeCityCode String? @map("callee_city_code") @db.VarChar(12)
calleeCityName String? @map("callee_city_name") @db.VarChar(80)
calleeProvinceName String? @map("callee_province_name") @db.VarChar(80)
@@ -561,6 +683,8 @@ model RawCdr {
vendorId String? @map("vendor_id") @db.VarChar(32)
vendorGatewayId String? @map("vendor_gateway_id") @db.VarChar(32)
lineGroupId String? @map("line_group_id") @db.VarChar(32)
landingCaller String? @map("landing_caller") @db.VarChar(64)
landingCallee String? @map("landing_callee") @db.VarChar(64)
startedAt DateTime @map("started_at") @db.DateTime(3)
answeredAt DateTime? @map("answered_at") @db.DateTime(3)
endedAt DateTime @map("ended_at") @db.DateTime(3)
@@ -577,6 +701,7 @@ model RawCdr {
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
customerGateway CustomerGateway? @relation(fields: [customerGatewayId], references: [id], onDelete: SetNull)
customerGatewayPolicy CustomerGatewayPolicy? @relation(fields: [customerGatewayPolicyId], references: [id], onDelete: SetNull)
businessPrefixRef BusinessPrefix? @relation(fields: [businessPrefixId], references: [id], onDelete: SetNull)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull)
vendorGateway VendorGateway? @relation(fields: [vendorGatewayId], references: [id], onDelete: SetNull)
lineGroup LandingLineGroup? @relation(fields: [lineGroupId], references: [id], onDelete: SetNull)
@@ -588,6 +713,7 @@ model RawCdr {
@@index([vendorId, startedAt])
@@index([calleeCityCode, startedAt])
@@index([calleeOperator, startedAt])
@@index([businessPrefixId, startedAt])
@@index([sipCode])
@@index([ratingStatus])
@@map("raw_cdrs")
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import { PrismaClient } from '@prisma/client';
const args = new Set(process.argv.slice(2));
const apply = args.has('--apply');
const json = args.has('--json');
const failOnBlockers = args.has('--fail-on-blockers');
if (args.has('--help') || args.has('-h')) {
console.log(`Usage: node scripts/phase2-gateway-migration.mjs [--apply] [--json] [--fail-on-blockers]
Default mode is dry-run. It audits legacy customer gateway policies and prints
which gateways can be safely migrated to the phase-2 single-line-group model.
Safe automatic migration requires:
- exactly one enabled legacy customer_gateway_policy per customer gateway;
- policy calleeMode = ANY;
- policy callerMode = ANY, or callerMode = PREFIX with callerValue.
All other cases are reported as blockers for manual review.`);
process.exit(0);
}
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL is required. Do not put credentials in command history; load it from the controlled secret environment.');
process.exit(2);
}
const prisma = new PrismaClient();
function id(prefix) {
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32 - prefix.length - 1)}`;
}
function blocker(gateway, code, message, policy = null) {
return {
gatewayId: gateway.id,
gatewayName: gateway.name,
customerId: gateway.customerId,
code,
message,
policyId: policy?.id ?? null,
policyName: policy?.name ?? null
};
}
function plannedChange(gateway, policy) {
const callerMode = policy.callerMode === 'PREFIX' ? 'PREFIXES' : 'ANY';
return {
gatewayId: gateway.id,
gatewayName: gateway.name,
customerId: gateway.customerId,
sourceIp: gateway.sourceIp,
lineGroupId: policy.lineGroupId,
callerMatchMode: callerMode,
callerPrefix: policy.callerMode === 'PREFIX' ? policy.callerValue : null,
calleeMatchMode: 'ANY',
policyId: policy.id,
policyName: policy.name
};
}
function analyzeGateway(gateway) {
const policies = gateway.policies;
if (policies.length === 0) {
return {
plan: null,
blocker: blocker(gateway, 'NO_ENABLED_POLICY', 'Gateway has no enabled legacy policy; choose a line group manually before switching hot path.')
};
}
if (policies.length > 1) {
return {
plan: null,
blocker: blocker(gateway, 'MULTIPLE_ENABLED_POLICIES', 'Gateway has multiple enabled legacy policies; split into multiple customer gateways or choose one rule manually.')
};
}
const [policy] = policies;
if (gateway.lineGroupId && gateway.lineGroupId !== policy.lineGroupId) {
return {
plan: null,
blocker: blocker(gateway, 'LINE_GROUP_CONFLICT', 'Gateway already has a phase-2 lineGroupId different from the legacy policy.', policy)
};
}
if (policy.calleeMode !== 'ANY') {
return {
plan: null,
blocker: blocker(gateway, 'CALLEE_RULE_REQUIRES_BUSINESS_PREFIX', 'Legacy callee matching cannot be inferred as a business prefix safely.', policy)
};
}
if (policy.callerMode === 'EQUALS') {
return {
plan: null,
blocker: blocker(gateway, 'CALLER_EQUALS_REQUIRES_MANUAL_PREFIX', 'Phase-2 supports caller ANY or custom prefixes; exact caller match must be reviewed manually.', policy)
};
}
if (policy.callerMode === 'PREFIX' && !policy.callerValue) {
return {
plan: null,
blocker: blocker(gateway, 'CALLER_PREFIX_EMPTY', 'Legacy caller prefix policy has an empty callerValue.', policy)
};
}
if (policy.callerMode !== 'ANY' && policy.callerMode !== 'PREFIX') {
return {
plan: null,
blocker: blocker(gateway, 'CALLER_RULE_UNSUPPORTED', `Unsupported legacy callerMode: ${policy.callerMode}`, policy)
};
}
return { plan: plannedChange(gateway, policy), blocker: null };
}
async function loadGateways() {
return prisma.customerGateway.findMany({
where: { deletedAt: null },
orderBy: [{ id: 'asc' }],
include: {
ips: { where: { deletedAt: null }, select: { sourceIp: true, status: true } },
callerPrefixes: { select: { prefix: true } },
policies: {
where: { deletedAt: null, status: 'ENABLED' },
orderBy: [{ priority: 'asc' }]
}
}
});
}
async function applyPlan(plans) {
if (plans.length === 0) {
return { updatedGateways: 0, insertedIps: 0, insertedCallerPrefixes: 0, outboxEvents: 0 };
}
return prisma.$transaction(async (tx) => {
let insertedIps = 0;
let insertedCallerPrefixes = 0;
for (const plan of plans) {
await tx.customerGateway.update({
where: { id: plan.gatewayId },
data: {
lineGroupId: plan.lineGroupId,
callerMatchMode: plan.callerMatchMode,
calleeMatchMode: 'ANY',
updatedBy: 'phase2-migration',
version: { increment: 1 }
}
});
if (plan.sourceIp) {
const result = await tx.customerGatewayIp.createMany({
data: [
{
id: id('cgip'),
gatewayId: plan.gatewayId,
sourceIp: plan.sourceIp,
status: 'ENABLED',
createdBy: 'phase2-migration',
updatedBy: 'phase2-migration'
}
],
skipDuplicates: true
});
insertedIps += result.count;
}
if (plan.callerPrefix) {
const result = await tx.customerGatewayCallerPrefix.createMany({
data: [
{
id: id('cgcp'),
gatewayId: plan.gatewayId,
prefix: plan.callerPrefix,
priority: 100,
createdBy: 'phase2-migration'
}
],
skipDuplicates: true
});
insertedCallerPrefixes += result.count;
}
}
await tx.outboxEvent.create({
data: {
id: id('out'),
aggregateType: 'customer_gateway_config',
aggregateId: 'phase2-gateway-migration',
eventType: 'phase2.gateway_migration.applied',
payload: {
gatewayIds: plans.map((plan) => plan.gatewayId),
safeGatewayCount: plans.length
}
}
});
return {
updatedGateways: plans.length,
insertedIps,
insertedCallerPrefixes,
outboxEvents: 1
};
});
}
function printHuman(summary) {
console.log(`Phase-2 gateway migration ${apply ? 'APPLY' : 'DRY-RUN'}`);
console.log(`Total gateways: ${summary.totalGateways}`);
console.log(`Safe to migrate: ${summary.safe.length}`);
console.log(`Already compatible: ${summary.alreadyCompatible.length}`);
console.log(`Manual blockers: ${summary.blockers.length}`);
if (summary.safe.length > 0) {
console.log('\nSafe plans:');
for (const plan of summary.safe) {
console.log(`- ${plan.gatewayId} ${plan.gatewayName}: lineGroup=${plan.lineGroupId}, caller=${plan.callerMatchMode}${plan.callerPrefix ? `:${plan.callerPrefix}` : ''}, callee=ANY`);
}
}
if (summary.blockers.length > 0) {
console.log('\nManual blockers:');
for (const item of summary.blockers) {
console.log(`- ${item.gatewayId} ${item.gatewayName}: ${item.code} - ${item.message}`);
}
}
if (summary.applyResult) {
console.log('\nApplied:');
console.log(`- updated gateways: ${summary.applyResult.updatedGateways}`);
console.log(`- inserted IP rows: ${summary.applyResult.insertedIps}`);
console.log(`- inserted caller prefixes: ${summary.applyResult.insertedCallerPrefixes}`);
console.log(`- outbox events: ${summary.applyResult.outboxEvents}`);
}
}
try {
const gateways = await loadGateways();
const safe = [];
const blockers = [];
const alreadyCompatible = [];
for (const gateway of gateways) {
const analysis = analyzeGateway(gateway);
if (analysis.blocker) {
blockers.push(analysis.blocker);
continue;
}
const plan = analysis.plan;
const hasIp = !plan.sourceIp || gateway.ips.some((item) => item.sourceIp === plan.sourceIp);
const hasCallerPrefix = !plan.callerPrefix || gateway.callerPrefixes.some((item) => item.prefix === plan.callerPrefix);
const compatible =
gateway.lineGroupId === plan.lineGroupId &&
gateway.callerMatchMode === plan.callerMatchMode &&
gateway.calleeMatchMode === 'ANY' &&
hasIp &&
hasCallerPrefix;
if (compatible) {
alreadyCompatible.push(plan);
} else {
safe.push(plan);
}
}
const summary = {
mode: apply ? 'apply' : 'dry-run',
totalGateways: gateways.length,
safe,
alreadyCompatible,
blockers,
applyResult: apply ? await applyPlan(safe) : null
};
if (json) {
console.log(JSON.stringify(summary, null, 2));
} else {
printHuman(summary);
}
if (failOnBlockers && blockers.length > 0) {
process.exitCode = 3;
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
} finally {
await prisma.$disconnect();
}