feat: add number library routing and cdr location support
This commit is contained in:
+243
-4
@@ -4,10 +4,10 @@
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 当前任务:无,S00-S30 本地 V2 闭环已完成
|
||||
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环冻结在 `s28-v2-20260621220924`,阿里云迁移前需按 S30 Runbook 重新演练
|
||||
- 最后更新:2026-06-22 10:35 +08:00
|
||||
- 当前阻塞:无。遗留风险为 Redis 恢复后 OpenSIPS 热路径可能需要重启 `opensips` 才恢复,已纳入 S30 Runbook,根因级修复需后续单独处理。
|
||||
- 当前任务:需求修改/二期变更:号码库第 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。
|
||||
- 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云
|
||||
- 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显
|
||||
|
||||
@@ -89,6 +89,245 @@
|
||||
|
||||
## 交接记录
|
||||
|
||||
### 2026-06-24 11:41 - 需求修改/二期变更:号码库第 6、7 步本地实现
|
||||
|
||||
- 状态:已完成本地代码;准备提交 Git;未发布到 B/A,未执行数据库迁移,未重启服务
|
||||
- 操作服务器:本地
|
||||
- 完成内容:CDR Stream 契约新增可选号码识别快照字段,并保持旧 Stream/pending 消息兼容;CDR Worker 入库 `raw_cdrs` 时写入 `calleeCityCode`、`calleeCityName`、`calleeProvinceName`、`calleeOperator`、`calleeNumberType`。新增 `CdrsModule`,提供 `GET /api/v2/cdrs` 与 `GET /api/v2/cdrs/:id`,复用 `cdr.view` 权限,支持主叫、被叫、客户网关、落地网关、地级市代码、运营商分页筛选,并返回客户/网关/线路组名称与 rated CDR 费用。Web 话单中心改为读取真实 CDR API,列表和详情展示地级市、运营商、号码类型、SIP 状态码、客户费用、成本和毛利。
|
||||
- 修改文件:`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/app.module.ts`、`apps/api/src/modules/cdrs/cdrs.module.ts`、`apps/api/src/modules/cdrs/cdrs.controller.ts`、`apps/api/src/modules/cdrs/cdrs.service.ts`、`apps/api/src/modules/cdrs/cdrs.repository.ts`、`apps/api/src/modules/cdrs/cdrs.service.spec.ts`、`apps/web/src/api.js`、`apps/web/src/App.jsx`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md`
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 pnpm `10.33.0`、Prisma `6.19.0`。
|
||||
- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 vitest run packages/redis/src/cdr-stream.spec.ts apps/worker-cdr/src/rating.spec.ts apps/api/src/modules/cdrs/cdrs.service.spec.ts`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 build`
|
||||
- 验证结果:typecheck 通过;目标测试 3 个文件、13 条测试通过;lint 通过;workspace build 通过;第一次将全量 test 与 build 并行执行时 3 个依赖本地服务的 e2e `beforeAll` 因资源争用超时,随后单独重跑 `corepack pnpm@10.33.0 test` 通过,25 个测试文件、85 条测试全部通过。
|
||||
- 回滚方式:恢复上述文件即可;本次未连接服务器、未发布 B/A、未写 Redis、未执行 MySQL migration、未重启 OpenSIPS,因此无远端回滚项。后续若发布,B 发布前需备份 MySQL 和当前 release;A 发布仍需先备份 OpenSIPS 配置/Lua 并执行 `opensips -C -f`。
|
||||
- 未解决问题:真实 80 万号段文件导入、Redis 发布容量评估、落地网关屏蔽地区 API/Web 管理入口、A/B/T 地区屏蔽端到端验收仍未实施;当前 CDR API 已能显示新字段,但历史话单在迁移前/旧消息场景下会显示 `UNKNOWN` 或 `-`。
|
||||
- 下一任务:提交 Git 后,建议优先补落地网关屏蔽地区管理入口和大批量号码库导入 Runbook,再安排 B/A 发布与 A/B/T 验收。
|
||||
|
||||
### 2026-06-24 11:31 - 需求修改/二期变更:号码库第 4、5 步本地实现
|
||||
|
||||
- 状态:已完成本地代码;未发布到 B/A,未执行数据库迁移,未重启服务
|
||||
- 操作服务器:本地
|
||||
- 完成内容:Config Publisher 快照新增地级市、手机 7 位号段、固话区号、运营商前缀和落地网关屏蔽地区;Redis 写入版本化 `geo_city`、`phone_segment`、`area_code`、`carrier_prefix` key,以及 `vendor_gateway:{id}:blocked_city_codes`/`blocked_province_codes` set。号码库导入新增 `number_library_config` outbox,导入后可触发发布器生成新快照。S28 OpenSIPS Lua 热路径新增被叫号码规范化、手机 7 位号段解析、固话区号解析、运营商前缀解析、落地网关城市/省份屏蔽跳过逻辑;全部可用网关都被地区屏蔽时返回 `NO_VENDOR_ROUTE_REGION_BLOCKED`。S28 `opensips.cfg` 更新 Lua SHA1、读取追加返回字段,并在成功/失败 CDR Stream 中携带 `callee_city_code`、`callee_city_name`、`callee_province_name`、`callee_operator`、`callee_number_type`。
|
||||
- 修改文件:`apps/worker-config-publisher/src/publisher.ts`、`apps/worker-config-publisher/src/publisher.spec.ts`、`apps/api/src/modules/number-library/number-library.repository.ts`、`infra/server-a/s28/lisglosips_hotpath.lua`、`infra/server-a/s28/opensips.cfg`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md`
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 pnpm `10.33.0`、Prisma `6.19.0`。
|
||||
- 执行的关键命令:`corepack pnpm@10.33.0 vitest run apps/worker-config-publisher/src/publisher.spec.ts apps/api/src/modules/number-library/number-library.e2e.spec.ts`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 build`、`Get-FileHash -Algorithm SHA1 infra/server-a/s28/lisglosips_hotpath.lua`
|
||||
- 验证结果:目标测试 2 个文件、4 条测试通过;typecheck 通过;lint 通过;全量 Vitest 24 个测试文件、82 条测试通过;workspace build 通过;本机无 `lua`、无 `opensips` 命令,因此未在本地执行 Lua 解释器语法检查和 `opensips -C`。
|
||||
- 回滚方式:恢复上述本地文件即可;本次未连接服务器、未发布 B/A、未写 Redis、未执行 MySQL migration、未重启 OpenSIPS,因此无远端回滚项。后续若发布:B 需先备份 MySQL/Redis 和当前 release,回滚 Redis 可切回 `cfg:previous_version`;A 需先备份 `/etc/opensips/opensips.cfg` 与 `/etc/opensips/lisglosips_hotpath.lua`,回滚时恢复备份、执行 `opensips -C -f /etc/opensips/opensips.cfg` 通过后重启 OpenSIPS。
|
||||
- 未解决问题:第 6 步 CDR Worker 尚未把 `callee_city_code`、`callee_city_name`、`callee_province_name`、`callee_operator`、`callee_number_type` 入库到 `raw_cdrs` 新字段;落地网关屏蔽地区仍缺少 Web/API 管理入口;真实 80 万号段文件导入、Redis 发布容量评估、A/B/T 端到端地区屏蔽验收仍未执行。
|
||||
- 下一任务:建议第 6 步改造 CDR Stream/Worker 入库号码识别字段,并补落地网关屏蔽地区的 API/Web 管理入口后,再安排 B/A 发布和 A/B/T 验收。
|
||||
|
||||
### 2026-06-24 11:15 - 需求修改/二期变更:号码库第 3 步 Web 本地实现
|
||||
|
||||
- 状态:已完成本地代码;未发布到 B,未执行数据库迁移
|
||||
- 操作服务器:本地
|
||||
- 完成内容:新增 Web “号码库”运营菜单,页面包含地级市字典、手机号码库、城市区号、运营商号码段规则四个 Tab。每个 Tab 接入 `/number-library/*` API,支持服务端查询入口、筛选、刷新、总数显示和批量 JSON 导入弹窗;导入成功后刷新当前 Tab。同步更新 V2 设计文档,将号码库菜单从三个 Tab 调整为四个 Tab,并补充第 3 步本地实施状态。
|
||||
- 修改文件:`apps/web/src/api.js`、`apps/web/src/App.jsx`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md`
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 pnpm `10.33.0`。
|
||||
- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、`corepack pnpm@10.33.0 test`
|
||||
- 验证结果:typecheck 通过;lint 通过;workspace build 通过;第一次将 test 与 build 并行执行时 5 个依赖本地服务的 e2e `beforeAll` 因资源争用超时,随后单独重跑 `corepack pnpm@10.33.0 test` 通过,23 个测试文件、81 条测试全部通过。
|
||||
- 回滚方式:恢复 `apps/web/src/api.js`、`apps/web/src/App.jsx`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` 本次改动即可;本次未连接服务器、未发布 B、未执行 MySQL migration、未写入 Redis、未重启服务,因此无远端回滚项。
|
||||
- 未解决问题:号码库前端当前使用批量 JSON 导入,只适合小批验证和接口联调;真实 80 万号段仍需文件导入/后台任务/进度与失败明细。号码解析服务、Config Publisher Redis 快照、OpenSIPS/Lua 屏蔽地区跳转、CDR Worker 新字段入库、话单中心展示与 A/B/T 端到端验收仍未实施。
|
||||
- 下一任务:建议第 4 步实现 Config Publisher/Redis 热路径快照前,先补大批量导入任务 Runbook 和文件导入接口,避免把 80 万号段通过普通 JSON 表单导入。
|
||||
|
||||
### 2026-06-24 11:07 - 需求修改/二期变更:号码库第 1、2 步本地实现
|
||||
|
||||
- 状态:已完成本地代码;未发布到 B,未执行数据库迁移
|
||||
- 操作服务器:本地
|
||||
- 完成内容:完成号码库第 1、2 步本地基线。Prisma Schema 和迁移新增 `geo_cities`、`phone_number_segments`、`phone_area_codes`、`carrier_prefix_rules`、`vendor_gateway_blocked_regions`,并在 `raw_cdrs` 预留被叫地级市、省份、运营商和号码类型快照字段。新增 `NumberLibraryModule`,提供城市、手机 7 位号段、固话区号、运营商前缀规则的分页查询与批量 upsert 导入 API,单批限制 1000 条,接入 RBAC 和审计。新增权限 `number_library.view`、`number_library.manage` 并写入 seed;更新 schema contract 和号码库 e2e 测试。
|
||||
- 修改文件:`prisma/schema.prisma`、`prisma/migrations/20260624095000_number_library/migration.sql`、`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/number-library/number-library.module.ts`、`apps/api/src/modules/number-library/number-library.controller.ts`、`apps/api/src/modules/number-library/number-library.service.ts`、`apps/api/src/modules/number-library/number-library.repository.ts`、`apps/api/src/modules/number-library/number-library.e2e.spec.ts`、`packages/auth/src/index.ts`、`packages/database/src/schema-contract.spec.ts`、`prisma/seed.ts`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md`
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 Prisma `6.19.0`、pnpm `10.33.0`。
|
||||
- 执行的关键命令:`corepack pnpm@10.33.0 prisma format`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 prisma:generate`、`corepack pnpm@10.33.0 vitest run apps/api/src/modules/number-library/number-library.e2e.spec.ts packages/database/src/schema-contract.spec.ts`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 build`
|
||||
- 验证结果:Prisma format/validate/generate 通过;号码库 e2e 与 schema contract 共 8 条测试通过;typecheck 通过;lint 通过;全量 Vitest 23 个测试文件、81 条测试通过;workspace build 通过。
|
||||
- 回滚方式:恢复上述本地文件改动即可;因为未连接服务器、未发布 B、未执行 MySQL migration、未导入真实数据,所以无远端回滚项。若后续发布到 B,执行迁移前必须先备份 B MySQL;回滚需恢复迁移前备份或执行经验证的反向迁移,并清理新增权限/seed 变更。
|
||||
- 未解决问题:真实 80 万号段文件导入、导入任务进度/失败明细、号码解析服务、Config Publisher Redis 发布、OpenSIPS/Lua 屏蔽地区跳转、CDR Worker 入库新字段、话单中心展示与筛选、A/B/T 端到端验收仍未实施。
|
||||
- 下一任务:建议第 3 步 Web “号码库”菜单和三个 Tab;或先实现第 4 步 Config Publisher/Redis 快照结构前的导入任务 Runbook。
|
||||
|
||||
### 2026-06-24 09:48 - 需求修改/二期变更:号码库与屏蔽地区路由需求评估
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:本地
|
||||
- 完成内容:评估新增“号码库”菜单及手机号码库、城市区号、运营商号码段规则三个 Tab 的影响范围;明确需要新增地级市字典 `geo_cities`,用于手机 7 位号段、固话区号、落地网关屏蔽地区和 CDR 快照统一引用;明确每通话单需要保存被叫地级市、省份、运营商和号码类型快照;明确落地网关屏蔽地区应在 OpenSIPS/Lua 热路径中跳过当前网关并尝试同一线路组下一落地网关。已将需求、数据模型建议、Redis 热路径结构、CDR 字段、路由规则、风险和 8 步实施拆分写入 `SOFTSWITCH_PLATFORM_DESIGN_V2.md`,文档版本升级为 V2.2。
|
||||
- 修改文件:`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md`
|
||||
- 安装软件及固定版本:未安装新软件。
|
||||
- 执行的关键命令:`rg` 检查现有 Schema、CDR Stream、OpenSIPS Lua 和 Web 中的地区/运营商/屏蔽地区相关实现;文档更新后用 `rg` 校验 V2.2、号码库、`geo_cities`、`phone_number_segments`、`carrier_prefix_rules`、`vendor_gateway_blocked_regions`、`NO_VENDOR_ROUTE_REGION_BLOCKED` 等关键条目。
|
||||
- 验证结果:本次为需求评估和 Markdown 文档更新,未运行 typecheck/lint/test/build;未连接服务器,未做数据库迁移、服务重启或数据导入。
|
||||
- 回滚方式:恢复 `SOFTSWITCH_PLATFORM_DESIGN_V2.md` 和 `IMPLEMENTATION_STATUS.md` 本次文档改动即可;不涉及 B release、MySQL、Redis、OpenSIPS、RTPEngine、Worker 或 Web 发布回滚。
|
||||
- 未解决问题:真实实施前仍需确认号码库数据来源、更新频率、导入格式、行政区划编码口径、运营商枚举口径、省级屏蔽是否需要、以及 80 万号段导入对 MySQL/Redis 内存和发布耗时的影响。
|
||||
- 下一任务:如确认实施,建议从“数据建模与迁移 + 导入 Runbook”单独开工,不要与 OpenSIPS 热路径改造混在同一次发布。
|
||||
|
||||
### 2026-06-24 09:31 - 需求修改/二期变更:用户与角色删除
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:本地、B
|
||||
- 完成内容:用户管理新增红色“删除”按钮和自研确认弹窗,确认后调用 `DELETE /api/v2/users/:id`;角色与权限新增红色“删除”按钮和自研确认弹窗,内置角色前端禁用删除,后端同时保护内置角色并在角色仍有关联用户时拒绝删除。后端用户删除为软删除:禁用账号、写入 `deletedAt`、清理用户角色关联以释放角色用户数;角色删除为软删除:禁用角色并写入 `deletedAt`。两个删除接口均接入 RBAC `users.manage`/`roles.manage` 和审计日志。未改 Prisma Schema,未执行数据库迁移。
|
||||
- 修改文件:`apps/api/src/modules/users/users.controller.ts`、`apps/api/src/modules/users/users.service.ts`、`apps/api/src/modules/users/users.repository.ts`、`apps/api/src/modules/roles/roles.controller.ts`、`apps/api/src/modules/roles/roles.service.ts`、`apps/api/src/modules/roles/roles.repository.ts`、`apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts`、`apps/api/src/modules/users-roles-delete.e2e.spec.ts`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 API/Web dist。
|
||||
- 安装软件及固定版本:未安装新软件。
|
||||
- 执行的关键命令:本地 `corepack pnpm@10.33.0 vitest run apps/api/src/modules/users-roles-delete.e2e.spec.ts`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 build`;B `tar --no-same-owner -xzf` 覆盖当前 release API/Web dist、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`curl http://127.0.0.1:3000/api/v2/health/ready`。
|
||||
- 验证结果:新增用户/角色删除 E2E 3 条通过;typecheck 通过;lint 通过;全量 Vitest 22 个测试文件、77 条测试通过;workspace build 通过。B `lisglosips@api` 与 `nginx` active,API ready 返回 `config/database/redis` 均 ok;服务日志确认已映射 `DELETE /api/v2/users/:id` 与 `DELETE /api/v2/roles/:id`;Web 首页 `https://100.90.90.91/` 返回 200 并引用新构建资源。
|
||||
- 回滚方式:B 已备份覆盖前 dist 到 `/var/backups/lisglosips-s41-user-role-delete/20260624T092950/api-dist` 与 `/var/backups/lisglosips-s41-user-role-delete/20260624T092950/web-dist`;如需回滚,将两目录恢复到 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/api/dist` 与 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/web/dist`,重启 `lisglosips@api` 并执行 `nginx -t && systemctl reload nginx`。本次无数据库 schema migration;若已通过新功能误删用户/角色,删除为软删除,需要在维护窗口受控恢复对应 `deleted_at/status`,用户还需按审计和业务确认恢复 `user_roles` 关联。
|
||||
- 未解决问题:用户管理、角色与权限的新增/编辑/启停仍沿用旧的前端本地态实现,未纳入本次需求重构;未做 A/B/T SIP 端到端,因为本次只涉及 B API/Web 管理面,不影响 Redis 热路径、OpenSIPS、RTPEngine、Worker 或三机呼叫链路。
|
||||
- 下一任务:等待下一个二期需求。
|
||||
|
||||
### 2026-06-23 22:07 - 需求修改/二期变更:S30 后需求写入 V2 设计文档
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:本地
|
||||
- 完成内容:将 S30 以后用户新增需求整理进 `SOFTSWITCH_PLATFORM_DESIGN_V2.md`,文档版本从 V2.0 调整为 V2.1,新增“25.5 S30 后二期需求变更”章节;同步更新 V2 范围表,纳入当前通话、受控删除、网关/线路组计数、人工充值记录语义、待设计菜单隐藏等内容;补充登录验证码、当前通话和强制挂断、删除/启停确认、充值与计费流水语义、动态路由和 CDR 补齐、前端交互、10 路虚拟呼叫测试工具、二期回归要求;修正 S27 描述中“待设计菜单继续保留标识”的旧表述。
|
||||
- 修改文件:`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md`
|
||||
- 安装软件及固定版本:未安装新软件。
|
||||
- 执行关键命令:`rg` 检查 V2 文档关键章节和条目;`git diff -- SOFTSWITCH_PLATFORM_DESIGN_V2.md --stat` 复核文档差异。
|
||||
- 验证结果:文档已包含 V2.1 状态、当前通话实施范围、待设计菜单隐藏说明、“25.5 S30 后二期需求变更”及 25.5.1-25.5.8 子章节;本次为纯 Markdown 文档更新,未运行 typecheck/lint/build。
|
||||
- 回滚方式:恢复 `SOFTSWITCH_PLATFORM_DESIGN_V2.md` 和 `IMPLEMENTATION_STATUS.md` 本次文档改动即可;不涉及服务器、数据库、Redis、OpenSIPS、RTPEngine 或 Worker 回滚。
|
||||
- 未解决问题:S40 10 路虚拟呼叫完整场景此前仍在 T 后台运行,本文档更新未等待其全程结束;若需确认最终结果,应继续查看 `/tmp/lisglosips-s40-virtual-calls-20260623T215430.log`。
|
||||
|
||||
### 2026-06-23 21:51 - 需求修改/二期变更:10 路虚拟呼叫测试工具与当前通话自动刷新
|
||||
|
||||
- 状态:已完成工具部署,完整 10 路场景已启动并运行中
|
||||
- 操作服务器:A、B、T、本地
|
||||
- 完成内容:当前通话页面新增默认开启的 5 秒自动刷新开关,保留手动刷新;T 新增 `/opt/lisglosips-s40/lisglosips-s40-virtual-calls.py`,可按 15 秒间隔发起 10 路虚拟呼叫,前 5 路先回 `180 Ringing`、45 秒后 `200 OK`、保持 600 秒后 BYE,后 5 路先回 `180 Ringing`、70 秒后 `480 Temporarily Unavailable`。脚本默认使用当前已打通策略的主叫 `s36-1001`、被叫 `13800136036`,并按 Call-ID 序号区分接通/不接通场景。首次完整场景暴露 A `fr_inv_timeout=30` 会在 45 秒接通前返回 408,已将 A OpenSIPS `fr_inv_timeout` 临时调为 95 并重启后重新启动场景。
|
||||
- 修改文件:`apps/web/src/App.jsx`、`infra/server-t/s40/lisglosips-s40-virtual-calls.py`、`infra/server-a/s28/opensips.cfg`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 `apps/web/dist`;T 已部署 `/opt/lisglosips-s40`;A 已就地修改 `/etc/opensips/opensips.cfg`。
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 Node.js `v22.22.2`、pnpm `10.33.0`、Vite `5.4.21`、T 既有 Python 3.12。
|
||||
- 执行关键命令:本地 `corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;B `tar -xzf` 覆盖 web dist、`nginx -t`、`systemctl reload nginx`、`curl -sk https://127.0.0.1/`;T `python3 -m py_compile /opt/lisglosips-s40/lisglosips-s40-virtual-calls.py`、短烟测 `--total 1 --answer-ring 2 --hold 3`、后台启动完整场景;A `opensips -C -f /etc/opensips/opensips.cfg`、`systemctl restart opensips`。
|
||||
- 验证结果:本地 typecheck/lint/build 均通过;B nginx 配置测试通过且 active,首页 HTML 返回新资源 `/assets/index-DwIXfg91.js` 和 `/assets/index-TA3aiKvG.css`;T 脚本 Python 编译通过。短烟测先发现 `50620` 被旧 S28 UAS 占用且新号码无策略,已调整为临时停旧 UAS、固定主叫/被叫并按 Call-ID 分流;最终 1 路短烟测通过:收到 `100 Giving it a try`、`180 Ringing`、延迟后 `200 OK`、3 秒后 BYE 且 UAS 返回 `200 OK`。首次完整场景因 A `fr_inv_timeout=30` 导致 45 秒接通前被 A 返回 408,已停止并恢复旧 UAS;A 超时调为 95 后重新启动完整场景,PID `32120`,日志 `/tmp/lisglosips-s40-virtual-calls-20260623T215430.log`,已验证第 1、2 路均在 45 秒后收到 `200 OK` 并进入 `connected hold=600.0s`。
|
||||
- 回滚方式:B 前端回滚可从 `/var/backups/lisglosips-s40-active-calls-autorefresh/20260623T214444/web-dist` 恢复当前 release 的 `apps/web/dist`,再执行 `nginx -t && systemctl reload nginx`。A 超时回滚可从 `/var/backups/lisglosips-s40-fr-inv-timeout/20260623T215330/opensips.cfg` 恢复 `/etc/opensips/opensips.cfg`,再执行 `opensips -C -f /etc/opensips/opensips.cfg && systemctl restart opensips`。T 脚本回滚可删除 `/opt/lisglosips-s40`,或从 `/var/backups/lisglosips-s40-virtual-calls/20260623T214907/lisglosips-s40` 恢复上一版。若需提前停止完整场景:在 T 执行 `sudo pkill -f /opt/lisglosips-s40/lisglosips-s40-virtual-calls.py`,然后确认 `100.93.185.30:50620` 有旧 S28 UAS;若未恢复,执行 `sudo nohup runuser -u nobody -- /usr/bin/python3 /opt/lisglosips-s28/lisglosips-s28-sip.py uas --host 100.93.185.30 --port 50620 >/tmp/lisglosips-s28-uas.log 2>&1 &`。
|
||||
- 未解决问题:完整 10 路场景仍在运行中,尚未等待约 12 分钟全程结束;测试会产生真实本地开发环境呼叫事件和少量计费/失败 CDR,使用的是现有 S36 测试客户网关/路由。当前通话页面观察地址为 `https://100.90.90.91/`。
|
||||
|
||||
### 2026-06-23 18:38 - 需求修改/二期变更:话单详情弹窗布局重排
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:B、本地
|
||||
- 完成内容:话单中心“查看详情”抽屉内部从单列 KeyValue 堆叠调整为主被叫概览、通话摘要、链路信息、时间轴和录音/信令操作分区;新增专用 `cdr-detail` 系列样式,移动端自动切为单列;补充本地 `Icon` 的 `arrow` 图标用于主叫到被叫方向展示。
|
||||
- 修改文件:`apps/web/src/App.jsx`、`apps/web/src/styles.css`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 `apps/web/dist`。
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 Node.js `v22.22.2`、pnpm `10.33.0`、Vite `5.4.21`。
|
||||
- 执行关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、B `tar -xzf` 覆盖 web dist、`nginx -t`、`systemctl reload nginx`、`curl -sk https://127.0.0.1/`。
|
||||
- 验证结果:本地 typecheck/lint/build 均通过;B nginx 配置测试通过且 active;首页 HTML 返回新资源 `/assets/index-CYrIkAAk.js` 和 `/assets/index-TA3aiKvG.css`。
|
||||
- 回滚方式:从 B `/var/backups/lisglosips-s39-cdr-detail-layout/20260623T183751/web-dist` 恢复当前 release 的 `apps/web/dist`,再执行 `nginx -t && systemctl reload nginx`;本地恢复 `apps/web/src/App.jsx`、`apps/web/src/styles.css` 的本次改动。
|
||||
- 未解决问题:未做浏览器截图交互验证;本次为纯前端抽屉布局调整,不涉及 API、数据库、Redis 热路径、OpenSIPS、RTPEngine 或 Worker。
|
||||
|
||||
### 2026-06-23 18:29 - 需求修改/二期变更:列表列宽优化与隐藏待设计菜单
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:B、本地
|
||||
- 完成内容:增强 `SimpleTable` 支持可选列宽和单元格 class;客户管理、客户网关管理、充值记录、供应商管理、落地网关管理页面的 ID/名称类列已适当收窄,并对长文本使用省略号避免撑宽列表;左侧菜单渲染时过滤 `pending` 项,隐藏“费率与计费”“SIP 运维”“监控告警”“系统设置”等待设计菜单。
|
||||
- 修改文件:`apps/web/src/App.jsx`、`apps/web/src/styles.css`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 `apps/web/dist`。
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 Node.js `v22.22.2`、pnpm `10.33.0`、Vite `5.4.21`。
|
||||
- 执行关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、B `tar -xzf` 覆盖 web dist、`nginx -t`、`systemctl reload nginx`、`curl -sk https://127.0.0.1/`。
|
||||
- 验证结果:本地 typecheck/lint/build 均通过;B nginx 配置测试通过且 active;首页 HTML 返回新资源 `/assets/index-N60ySjGq.js` 和 `/assets/index-uhdBdVZH.css`。
|
||||
- 回滚方式:从 B `/var/backups/lisglosips-s38-web-ui-layout/20260623T182343/web-dist` 恢复当前 release 的 `apps/web/dist`,再执行 `nginx -t && systemctl reload nginx`;本地恢复 `apps/web/src/App.jsx`、`apps/web/src/styles.css` 的本次改动。
|
||||
- 未解决问题:未做浏览器截图交互验证;本次为纯前端布局/菜单渲染调整,不涉及 API、数据库、Redis 热路径、OpenSIPS、RTPEngine 或 Worker。
|
||||
|
||||
### 2026-06-23 17:47 - 需求修改/二期变更:自研确认弹窗与删除按钮警示色
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:B、本地
|
||||
- 完成内容:新增通用 `ConfirmDialog`,按现有 Modal/UI 风格替换原生 `window.confirm` 和旧确认弹窗;当前通话强制挂断、客户/供应商/客户网关/落地网关/落地线路组删除、客户网关/落地网关启停、策略删除、抽检规则删除、重置密码确认均使用自研弹窗;所有删除按钮改为 `danger` 红色警示态,包括表格删除、编辑抽屉中的删除行按钮以及线路组移除网关按钮。
|
||||
- 修改文件:`apps/web/src/App.jsx`、`apps/web/src/styles.css`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 `apps/web/dist`。
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 Node.js `v22.22.2`、pnpm `10.33.0`、Vite `5.4.21`。
|
||||
- 执行关键命令:`rg 'window\.confirm' apps/web/src/App.jsx`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、B `tar -xzf` 覆盖 web dist、`nginx -t`、`systemctl reload nginx`、`curl -sk https://127.0.0.1/`。
|
||||
- 验证结果:本地 typecheck/lint/build 均通过;`rg 'window\.confirm' apps/web/src/App.jsx` 无匹配;B nginx 配置测试通过且 active;首页 HTML 返回新资源 `/assets/index-CWnw3J5O.js` 和 `/assets/index-BuNsq8w9.css`。
|
||||
- 回滚方式:从 B `/var/backups/lisglosips-s37-confirm-dialog-ui/20260623T094434/web-dist` 恢复当前 release 的 `apps/web/dist`,再执行 `nginx -t && systemctl reload nginx`;本地恢复 `apps/web/src/App.jsx`、`apps/web/src/styles.css` 的本次改动。
|
||||
- 未解决问题:未做浏览器交互截图验证;功能行为已通过构建和页面资源烟测。
|
||||
|
||||
### 2026-06-23 16:20 - 需求修改/二期变更:充值记录过滤、充值性能与新建链路通话测试
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:A、B、T、本地
|
||||
- 完成内容:客户话单消费不再写入 `customer_recharges`,CDR worker 仅写 raw/rated CDR 并扣客户余额;充值记录 API 过滤历史 `worker-cdr`/`cdr:`/`CDR_CHARGE:` 客户消费流水;Web 客户/供应商充值成功后改为局部更新余额与充值列表,不再触发全量 `refreshApi()`,避免供应商充值后长时间停留在“拉取API”。完整流程测试时发现 A 热路径仍硬编码成功 CDR 和落地网关,已新增 S28 Lua 动态选择线路组首个启用落地网关,并让 OpenSIPS 成功 CDR 从 dialog 变量写入动态 customer/gateway/policy/vendorGateway/lineGroup;补齐 B `/etc/lisglosips/config-publisher.env` 并启动 `lisglosips@config-publisher`;修复 CDR worker 对超大 `config_version` 写 MySQL INT 越界的问题。
|
||||
- 修改文件:`apps/worker-cdr/src/rating.ts`、`apps/worker-cdr/src/rating.spec.ts`、`apps/api/src/modules/recharges/recharges.repository.ts`、`apps/web/src/App.jsx`、`infra/server-a/s28/opensips.cfg`、`infra/server-a/s28/lisglosips_hotpath.lua`、`IMPLEMENTATION_STATUS.md`;B 当前 release 的 `apps/api/dist`、`apps/web/dist`、`apps/worker-cdr/dist` 已覆盖部署;A `/etc/opensips/opensips.cfg` 与 `/usr/local/lib/lisglosips/lisglosips_hotpath.lua` 已更新;B 新增 `/etc/lisglosips/config-publisher.env`。
|
||||
- 验证结果:本地 `corepack pnpm@10.33.0 typecheck` 通过;`corepack pnpm@10.33.0 exec vitest run apps/worker-cdr/src/rating.spec.ts apps/api/src/modules/recharges/recharges.e2e.spec.ts --maxWorkers=1` 通过;全量 `corepack pnpm@10.33.0 exec vitest run --maxWorkers=1` 21 个测试文件 74 条测试通过;最终 `lint` 与全 workspace `build` 通过。B API `/api/v2/health/ready` 正常;客户充值记录按测试客户查询仅 1 条人工充值,未出现 CDR 消费充值;供应商充值接口实测约 818ms,客户充值约 1063ms。A `opensips -C` 通过并 active;Redis active config 版本 `1782201896870` 下 `auth:ip:100.93.185.30` 命中新建 `cgw_zz_s36_20260623160046`。完整流程测试创建供应商、落地网关、落地线路组、客户、客户网关、策略、客户充值、供应商充值后,由 T 发起 `s36-1001 -> 13800136036`,当前通话 API 显示 `callerIp=100.93.185.30`、`landingIp=100.93.185.30`,强制挂断返回 `HANGUP_IN_PROGRESS`,随后当前通话列表清空;最终 CDR `raw_66b73108dc1e485ab17c6ac52a9464c1` 为 `RATED`,rated CDR `customer_fee=0.012000`、`vendor_cost=0.012000`,测试客户余额从 `10.000000` 扣为 `9.988000`。
|
||||
- 回滚方式:B 应用回滚可从 `/var/backups/lisglosips-s36-recharge-flow/20260623T074955` 恢复 `api-dist`、`web-dist`、`worker-cdr-dist` 并重启 `lisglosips@api`、`lisglosips@cdr-worker`;worker 后续补丁可从 `/var/backups/lisglosips-s36-worker-cdr-config-version/20260623T081403` 恢复。A OpenSIPS 热路径回滚点包括 `/var/backups/lisglosips-s36-hotpath/20260623T080010` 和 `/var/backups/lisglosips-s36-dialog-cdr/20260623T080956`,恢复 `/etc/opensips/opensips.cfg` 后执行 `opensips -C -f /etc/opensips/opensips.cfg` 并重启 `opensips`;B config-publisher env 回滚点为 `/var/backups/lisglosips-s36-config-publisher-env/20260623T080456`。测试业务数据使用前缀 `S36 Flow 20260623160046`,如需清理,应按 API 软删除客户网关/策略、线路组、落地网关、客户和供应商,不直接删除充值/话单审计记录。
|
||||
- 遗留问题:测试实体当前保留启用,T 源 IP 会命中新建测试客户网关;若后续要恢复 S28 原始测试路由,应软删除或停用该测试客户网关并等待 config-publisher 发布。两次早期测试呼叫因 A 成功 CDR 动态变量和 worker `config_version` 越界进入 `stream:cdr_deadletter`,未进入 raw/rated CDR,已作为本次排障证据保留。T 在测试中曾出现一次 SSH banner 超时和一次 ping 丢包,重试后恢复。
|
||||
|
||||
### 2026-06-23 15:36 - 需求修改/二期变更:列表计数、删除功能和启停确认
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:B、本地
|
||||
- 完成内容:供应商列表显示落地网关数;客户管理、供应商管理前端增加删除按钮和确认弹窗,后端沿用已有软删除并分别在客户网关数/落地网关数大于 0 时拒绝删除。客户网关、落地网关后端新增 `DELETE` 软删除接口并纳入审计;客户网关删除会同步软删该网关下的路由策略并发布配置 outbox;落地网关删除在仍被未删除落地线路组引用时拒绝,避免热配置断链。落地线路组 API 新增 `customerGatewayCount`,按正在使用该线路组的去重客户网关数统计;删除落地线路组时若该数大于 0 则拒绝。Web 客户网关、落地网关启用/禁用前增加确认弹窗;客户网关、落地网关、落地线路组页面增加删除按钮和确认弹窗,并接入真实 API 列表/操作。
|
||||
- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/api/src/modules/customer-gateways/customer-gateways.controller.ts`、`apps/api/src/modules/customer-gateways/customer-gateways.service.ts`、`apps/api/src/modules/customer-gateways/customer-gateways.repository.ts`、`apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts`、`apps/api/src/modules/vendor-gateways/vendor-gateways.controller.ts`、`apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts`、`apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts`、`apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts`、`apps/api/src/modules/landing-line-groups/landing-line-groups.repository.ts`、`apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts`、`apps/web/src/api.js`、`apps/web/src/App.jsx`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中覆盖 API/Web dist。
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Prisma、Nginx。
|
||||
- 执行的关键命令:本地 `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/customer-gateways/customer-gateways.e2e.spec.ts apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts apps/api/src/modules/customers/customers.e2e.spec.ts --maxWorkers=1`、`corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/vendors/vendors.e2e.spec.ts --maxWorkers=1`;B `tar -xzf` 覆盖当前 release API/Web dist、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`curl http://127.0.0.1:3000/api/v2/health/ready`
|
||||
- 验证结果:typecheck、lint、build 均通过。相关 e2e 验证通过:客户、供应商、客户网关、落地网关、落地线路组;一次组合运行中 `vendors.e2e` 因 Nest 多模块启动超过 10 秒 hookTimeout 被跳过/失败,随后单独运行 `vendors.e2e` 3 条测试全部通过。最终全量 Vitest 单 worker 模式 21 个测试文件、74 条测试通过。B `lisglosips@api` 与 `nginx` active,API ready 返回 `config/database/redis` 均 ok。B 只读烟测通过:`/vendors` 返回 `gatewayCount`,`/landing-line-groups` 返回 `customerGatewayCount`;当前本地数据示例 `ven_s28_t.gatewayCount=1`、`llg_s28_t.customerGatewayCount=1`。
|
||||
- 回滚方式:B 已备份覆盖前 dist 到 `/var/backups/lisglosips-s35-delete-count-confirm/20260623T153500/api-dist` 与 `/var/backups/lisglosips-s35-delete-count-confirm/20260623T153500/web-dist`;如需回滚,将两目录恢复到 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/api/dist` 与 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/web/dist`,重启 `lisglosips@api` 并 `nginx -t && systemctl reload nginx`。本次无数据库 schema migration;如已通过新功能执行业务删除,删除为软删除,需要按具体对象在维护窗口受控恢复 `deleted_at/status` 与相关 outbox。
|
||||
- 未解决问题:落地网关删除目前在被落地线路组引用时会拒绝,比本次需求更保守,目的是避免线路组热配置引用已删除网关;如果后续希望“删除落地网关时自动从线路组移除”,需单独设计优先级重排和配置发布语义。
|
||||
- 下一任务:继续等待下一项二期需求;阿里云迁移仍不要直接开始。
|
||||
|
||||
### 2026-06-23 15:12 - 需求修改/二期变更:当前通话增加呼叫方 IP 与落地 IP
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:B、T、本地
|
||||
- 完成内容:在当前通话 API 归一化结果中新增 `callerIp` 与 `landingIp` 字段;解析优先级为 SIP Contact、SDP `c=IN IP4/IP6`、SIP URI、bind addr 兜底。Web “当前通话 / 实时呼叫列表”新增“呼叫方 IP”和“落地 IP”两列。该变更不涉及数据库 schema、Redis 热路径、OpenSIPS 配置、RTPEngine 或 Worker。
|
||||
- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/api/src/modules/active-calls/active-calls.service.ts`、`apps/api/src/modules/active-calls/active-calls.service.spec.ts`、`apps/web/src/App.jsx`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中覆盖 API/Web dist。
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Nginx。
|
||||
- 执行的关键命令:本地 `corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/active-calls/active-calls.service.spec.ts --maxWorkers=1`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;B `tar -xzf` 覆盖当前 release API/Web dist、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`curl http://127.0.0.1:3000/api/v2/health/ready`;T `/opt/lisglosips-s28/lisglosips-s28-sip.py invite --timeout 20 --hold 60`
|
||||
- 验证结果:当前通话单测 5 条通过;typecheck、lint、build 均通过。B `lisglosips@api` 与 `nginx` active,API ready 返回 `config/database/redis` 均 ok。A/B/T 端到端验证:T 发起保持呼叫后,B API 查询到当前通话并返回 `callerIp=100.93.185.30`、`landingIp=100.93.185.30`;当前本地 T 同时模拟主叫与落地 UAS,因此两者相同符合测试拓扑。API 挂断返回 `HANGUP_IN_PROGRESS`,等待 OpenSIPS 删除延迟后当前通话列表清空。
|
||||
- 回滚方式:B 已备份覆盖前 dist 到 `/var/backups/lisglosips-s34-active-call-ips/20260623T150800/api-dist` 与 `/var/backups/lisglosips-s34-active-call-ips/20260623T150800/web-dist`;如需回滚,将两目录恢复到 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/api/dist` 与 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/web/dist`,重启 `lisglosips@api` 并 `nginx -t && systemctl reload nginx`。无数据库、A/OpenSIPS、Redis、RTPEngine 回滚项。
|
||||
- 未解决问题:真实生产落地网关若不返回 SIP Contact 或 SDP 连接地址,`landingIp` 会退回到可解析的 URI/bind 地址,可能显示为 OpenSIPS 本地地址;后续可在路由侧显式写入 dialog 变量以获得更精确的落地网关 IP。
|
||||
- 下一任务:继续等待下一项二期需求;阿里云迁移仍不要直接开始。
|
||||
|
||||
### 2026-06-23 10:15 - 需求修改/二期变更:新增“当前通话”菜单与强制挂断
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:A、B、T、本地
|
||||
- 完成内容:新增 Web “业务 / 当前通话”菜单,可刷新查看 OpenSIPS 当前 Dialog 列表,展示主叫、被叫、Call-ID、状态、时长、联系方式,并提供确认后的“挂断”按钮。B API 新增 `GET /api/v2/active-calls` 与 `POST /api/v2/active-calls/:id/hangup`,使用新增权限 `active_calls.view`、`active_calls.manage` 和审计拦截器;超级管理员具备全部权限,技术运维具备查看/挂断,运营管理员仅查看。A 启用 OpenSIPS `dialog.so` 并在 INVITE 路径执行 `create_dialog()`;新增受限 SSH forced-command `/usr/local/sbin/lisglosips-call-control`,仅允许 `dlg_list` 与 `dlg_end_dlg`,MI HTTP 仍绑定本机 `127.0.0.1:8888`。B 新增专用 `lisglosips` 服务用户出站 SSH key 与 `/etc/lisglosips/call-control/ssh_config`,API 通过该受限通道访问 A;修复 `AUTH_COOKIE_SECURE` 和 `ACTIVE_CALLS_SSH_HOST` 空值导致的 B API 启动/调用兼容问题。
|
||||
- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/api/src/modules/active-calls/active-calls.controller.ts`、`apps/api/src/modules/active-calls/active-calls.module.ts`、`apps/api/src/modules/active-calls/active-calls.service.ts`、`apps/api/src/modules/active-calls/active-calls.service.spec.ts`、`apps/api/src/modules/active-calls/opensips-mi.client.ts`、`apps/api/src/modules/app.module.ts`、`apps/api/src/shared/config.ts`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`packages/auth/src/index.ts`、`prisma/seed.ts`、`infra/server-a/s28/opensips.cfg`、`infra/server-a/s32/lisglosips-call-control`;A `/etc/opensips/opensips.cfg`、A `/usr/local/sbin/lisglosips-call-control`、B `/etc/lisglosips/api.env`、B `/etc/lisglosips/call-control/*`、B release `/opt/lisglosips/releases/s33-active-calls-20260623094000`
|
||||
- 安装软件及固定版本:未安装新软件;使用 A 既有 OpenSIPS `3.4.11` dialog 模块、B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Prisma、MySQL、Redis、Nginx。
|
||||
- 执行的关键命令:本地 `corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 exec vitest run --maxWorkers=1`、`corepack pnpm@10.33.0 build`;A `opensips -C -f`、`systemctl restart opensips`、`systemctl is-active opensips rtpengine`;B `systemctl start lisglosips-backup.service`、`corepack pnpm@10.33.0 db:seed`、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`curl http://127.0.0.1:3000/api/v2/health/ready`;T `/opt/lisglosips-s28/lisglosips-s28-sip.py invite --timeout 20 --hold 60`
|
||||
- 验证结果:本地 lint 通过;typecheck 通过;全量 Vitest 单 worker 模式 21 个测试文件、73 条测试通过;workspace build 通过。A OpenSIPS 配置语法检查通过并已重启,`opensips`、`rtpengine` active;B `lisglosips@api`、`nginx`、`mysql`、`redis-server` active,API ready 返回 `config/database/redis` 均 ok。未登录访问 `/api/v2/active-calls` 返回 401;登录态访问空闲列表返回 `source=opensips-mi,total=0`。A/B/T 端到端验收:T 发起保持呼叫后,B API 查询到 1 条当前通话;调用 B API 挂断返回 `HANGUP_IN_PROGRESS`,OpenSIPS Dialog 状态进入 `5`,等待删除延迟后 B API 当前通话列表清空。
|
||||
- 回滚方式:B 应用回滚可将 `/opt/lisglosips/current` 切回上一 release `/opt/lisglosips/releases/s32-captcha-admin-20260622124659`,重启 `lisglosips@api` 并 reload Nginx;如需还原 B 环境变量,使用 `/var/backups/lisglosips-s33-active-calls-b/20260623T014402Z/api.env`。A 回滚可从 `/var/backups/lisglosips-s33-active-calls/20260623T014315Z` 恢复 `/etc/opensips/opensips.cfg` 后重启 `opensips`,并移除 `/usr/local/sbin/lisglosips-call-control`、`lisglo-callctl` 用户/authorized_keys 以及 B `/etc/lisglosips/call-control` 专用 key。数据库无 schema migration;新增权限/角色授权来自 seed,可保留,若需清理需维护窗口执行受控 SQL。B 发布前 MySQL/Redis 备份为 `/data/backups/mysql/20260623T014114Z` 与 `/data/backups/redis/20260623T014114Z`。
|
||||
- 未解决问题:OpenSIPS HTTP MI 的 `dlg_end_dlg` 在实际终止 Dialog 时会返回 `500 Operation failed`,但 Dialog 会进入 state `5` 并随后清空;API 已按“500 后复查消失或 state 5 则视为已受理”处理。当前通话列表依赖 OpenSIPS dialog 模块,只覆盖本地 KVM A 单节点;未来多 OpenSIPS 节点需按节点聚合。强制挂断会影响被选中的实时呼叫,操作已纳入 RBAC 与审计,生产迁移前需再次演练。
|
||||
- 下一任务:继续等待下一项二期需求;阿里云迁移仍不要直接开始。
|
||||
|
||||
### 2026-06-22 14:20 - 需求修改/二期变更:修复登录验证码刷新后不显示
|
||||
|
||||
- 状态:已完成,等待用户浏览器复验
|
||||
- 操作服务器:B、本地
|
||||
- 完成内容:定位验证码接口返回的是 `data:image/svg+xml;base64,...` SVG 图片,而 B Nginx 安全头 `Content-Security-Policy` 未显式允许 `img-src data:`,浏览器会拦截验证码图片渲染,表现为“验证码刷新不出来”。已将仓库和 B 当前 Nginx 安全头更新为 `default-src 'self'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'`,保留原有同源、禁止嵌入和禁止 object 的安全基线。
|
||||
- 修改文件:`IMPLEMENTATION_STATUS.md`、`infra/server-b/s05/nginx/lisglosips-security-headers.conf`;B `/etc/nginx/snippets/lisglosips-security-headers.conf`
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Nginx `1.24.0-2ubuntu7.12` 和当前 release `/opt/lisglosips/releases/s32-captcha-admin-20260622124659`
|
||||
- 执行的关键命令:本地 `corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 exec vitest run --maxWorkers=1`、`corepack pnpm@10.33.0 build`;B `cp -a` 备份 Nginx 安全头、`nginx -t`、`systemctl reload nginx`、`curl -k -sI https://127.0.0.1/`、`curl http://127.0.0.1:3000/api/v2/auth/captcha`
|
||||
- 验证结果:本地 lint、typecheck、Prisma validate 均通过;全量 Vitest 单 worker 模式 20 个测试文件、69 条测试通过;workspace build 通过。B 当前 release 确认为 `/opt/lisglosips/releases/s32-captcha-admin-20260622124659`;`nginx -t` 通过并已 reload;HTTPS 首页响应头已返回 `Content-Security-Policy: default-src 'self'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'`;`GET http://127.0.0.1:3000/api/v2/auth/captcha` 返回新的 `captchaId`、`data:image/svg+xml;base64,...` 图片和过期时间;B `nginx`、`lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker` 均 active。本次未做 A/B/T SIP 呼叫链路,因为变更只涉及 B Web 安全头,不影响 Redis 热路径、OpenSIPS、RTPEngine、CDR 或录音链路。
|
||||
- 回滚方式:B 已备份原安全头到 `/var/backups/lisglosips-s32-captcha-csp/20260622T061944Z/lisglosips-security-headers.conf`;如需回滚,恢复该文件到 `/etc/nginx/snippets/lisglosips-security-headers.conf`,执行 `nginx -t` 通过后 `systemctl reload nginx`。仓库侧回滚 `infra/server-b/s05/nginx/lisglosips-security-headers.conf` 的本次一行 CSP 修改即可。无数据库、Redis、服务 release、OpenSIPS 或 RTPEngine 回滚项。
|
||||
- 未解决问题:尚未由用户在真实浏览器中点击验证码刷新并确认图片可见;验证码仍为 API 单进程内存存储,后续多 API 实例/生产高可用建议迁移到 Redis 或共享缓存。
|
||||
- 下一任务:用户浏览器复验 B 登录页验证码刷新;继续等待下一项二期需求。
|
||||
|
||||
### 2026-06-22 12:54 - 需求修改/二期变更发布到 B:登录图形验证码与 admin 初始账号
|
||||
|
||||
- 状态:已完成,等待用户验收
|
||||
- 操作服务器:B、本地
|
||||
- 完成内容:在登录接口新增一次性图形验证码,`GET /api/v2/auth/captcha` 生成 5 位 SVG 验证码并只返回 `captchaId`、图片 data URL 和过期时间;`POST /api/v2/auth/login` 必须携带 `captchaId` 和 `captchaCode`,验证码错误返回 `401 AUTH_CAPTCHA_INVALID`,缺少登录字段继续返回统一认证失败以降低账号枚举风险。Web 登录页新增验证码图片、输入框和刷新按钮,登录失败后自动刷新验证码。B 数据库已新增/更新用户名 `admin`,启用状态,授予超级管理员角色,并按用户提供的初始口令写入 Argon2id 哈希;未在文档或命令输出中记录明文口令。发布为 B 新 release `/opt/lisglosips/releases/s32-captcha-admin-20260622124659`,`/opt/lisglosips/current` 已切换到该 release。
|
||||
- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/api/src/modules/auth/auth.controller.ts`、`apps/api/src/modules/auth/auth.module.ts`、`apps/api/src/modules/auth/captcha.service.ts`、`apps/api/src/modules/auth/auth.e2e.spec.ts`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`apps/web/src/styles.css`;B 新 release 中覆盖 API/Web dist 与对应源码。延续上一轮登录防爆破相关文件:`apps/api/src/modules/auth/auth.service.ts`、`apps/api/src/modules/auth/auth.service.spec.ts`、`apps/api/src/shared/config.ts`。
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Prisma、Nginx、Grafana `13.0.2`、Prometheus `2.45.3`。
|
||||
- 执行的关键命令:本地 `corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 exec vitest run --maxWorkers=1`、`corepack pnpm@10.33.0 build`、`tar -czf` 打包、`scp` 上传;B `systemctl start lisglosips-backup.service`、`cp -a` 创建新 release、`tar --no-same-owner -xzf` 覆盖产物、`ln -sfn` 切换 current、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`/tmp/lisglosips-release-preflight.sh`;B 通过临时脚本按用户提供口令 upsert `admin` 用户并撤销旧会话。
|
||||
- 验证结果:本地 lint、typecheck、Prisma validate、build 均通过;全量 Vitest 单 worker 模式 20 个测试文件、69 条测试通过,新增认证 e2e 覆盖 captcha 接口不泄露答案、无验证码登录被拒绝、携带正确验证码后可登录。B 发布前备份目录为 MySQL `/data/backups/mysql/20260622T044834Z`、Redis `/data/backups/redis/20260622T044834Z`,两者 `SHA256SUMS` 校验通过。B preflight 通过:MySQL、Redis、Nginx、API、CDR Worker、Recording Worker、Heplify、Prometheus、Grafana 均 active,API ready 返回 ok,Nginx 配置测试成功。`GET http://127.0.0.1:3000/api/v2/auth/captcha` 返回 captcha JSON 与 SVG data URL 且不含答案;使用错误验证码调用登录返回 `401 AUTH_CAPTCHA_INVALID`;`https://127.0.0.1/` 返回新前端构建资产 `index-B6ms7F5U.js` 与 `index-C60rDiFV.css`。
|
||||
- 回滚方式:如需回滚应用,按 `docs/S30_RELEASE_BACKUP_RUNBOOK.md` 将 B `/opt/lisglosips/current` 切回上一稳定 release `/opt/lisglosips/releases/s31-auth-login-20260622115851` 或原冻结 release `/opt/lisglosips/releases/s28-v2-20260621220924`,重启 `lisglosips@api` 并 reload Nginx。数据库只新增/更新 `admin` 用户密码哈希和会话撤销记录;如需回滚账号变更,可在维护窗口按 B 发布前 MySQL 备份 `/data/backups/mysql/20260622T044834Z` 恢复,或单独执行受控账号禁用/密码重置脚本,执行前需再次说明影响和回滚点。
|
||||
- 未解决问题:尚未由用户在浏览器中完成真实登录验收;验证码当前为单 API 进程内存存储,适配当前 B 单实例开发环境,后续多 API 实例/生产高可用应迁移到 Redis 或共享缓存。登录防爆破仍为进程内存级节流,生产多实例同样建议升级为 Redis/网关级共享限流。本次未做 A/B/T SIP 呼叫链路,因为变更只涉及 Web/API 登录鉴权和 B 用户数据,不影响 Redis 热路径、OpenSIPS、RTPEngine、CDR 或录音链路。
|
||||
- 下一任务:用户验收 B 登录页;继续等待下一项二期需求。
|
||||
|
||||
### 2026-06-22 12:04 - 需求修改/二期变更发布到 B:登录页与登录防爆破
|
||||
|
||||
- 状态:已完成,等待用户验收
|
||||
- 操作服务器:B、本地
|
||||
- 完成内容:将本地登录页与登录防爆破变更发布为 B 新 release `/opt/lisglosips/releases/s31-auth-login-20260622115851`,`/opt/lisglosips/current` 已切换到该 release。发布方式为复制上一 release `/opt/lisglosips/releases/s28-v2-20260621220924` 后覆盖本次 API/Web 构建产物和相关源码;未执行数据库迁移,未改 Redis 配置,未触碰 A/T、OpenSIPS 或 RTPEngine。发布前触发 B MySQL/Redis 备份并校验哈希。
|
||||
- 修改文件:本地 `IMPLEMENTATION_STATUS.md`;B 新 release 中的 `apps/api/dist/`、`apps/api/src/modules/auth/auth.service.ts`、`apps/api/src/modules/auth/auth.service.spec.ts`、`apps/api/src/shared/config.ts`、`apps/web/dist/`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`apps/web/src/styles.css`
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Prisma、Nginx、Grafana `13.0.2`、Prometheus `2.45.3`
|
||||
- 执行的关键命令:本地 `corepack pnpm@10.33.0 build`、`tar -czf` 打包发布覆盖包、`scp` 上传;B `systemctl start lisglosips-backup.service`、`cp -a` 创建新 release、`tar --no-same-owner -xzf` 覆盖产物、`ln -sfn` 切换 current、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`/tmp/lisglosips-release-preflight.sh`
|
||||
- 验证结果:发布前备份目录为 MySQL `/data/backups/mysql/20260622T040339Z`、Redis `/data/backups/redis/20260622T040339Z`,两者 `SHA256SUMS` 校验通过。B preflight 通过:MySQL、Redis、Nginx、API、CDR Worker、Recording Worker、Heplify、Prometheus、Grafana 均 active,API ready 返回 ok,Nginx 配置测试成功。`GET http://127.0.0.1:3000/api/v2/health/ready` 返回 ok;未登录访问 `/api/v2/dashboard/summary` 返回 `401 AUTH_REQUIRED`;`https://127.0.0.1/` 返回新前端构建资产 `index-7KkZS1Fz.js` 与 `index-ChBVfysf.css`。Grafana health `http://127.0.0.1:3001/api/health` 返回 database ok/version `13.0.2`,Prometheus `/-/ready` 返回 ready。
|
||||
- 回滚方式:B 应用可执行 `sudo /tmp/lisglosips-release-preflight.sh` 复核状态;如需回滚,按 `docs/S30_RELEASE_BACKUP_RUNBOOK.md` 将 `/opt/lisglosips/current` 切回 `/opt/lisglosips/releases/s28-v2-20260621220924`,重启 `lisglosips@api` 并 reload Nginx;数据库和 Redis 未变更,备份目录仅作为发布前恢复点保留。
|
||||
- 未解决问题:尚未由用户在浏览器中完成真实登录验收;本次未做 A/B/T SIP 呼叫链路,因为变更只涉及 Web/API 登录鉴权,不影响 Redis 热路径、OpenSIPS、RTPEngine、CDR 或录音链路。后续多 API 实例/生产环境仍建议把登录节流升级为 Redis 或网关级共享限流。
|
||||
- 下一任务:用户验收登录页面;继续等待下一项二期需求。
|
||||
|
||||
### 2026-06-22 11:53 - 需求修改/二期变更:登录页与登录防爆破
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:本地
|
||||
- 完成内容:完善 Web 登录闭环,打开应用时先尝试 `/api/v2/auth/refresh` 恢复会话,未认证时只显示登录页面;输入用户名和密码调用 `/api/v2/auth/login`,登录成功后保存 access token 并加载业务数据;顶部显示当前登录用户并支持退出,退出时调用 `/api/v2/auth/logout`、清理本地 token 和页面数据。后端在既有 Argon2id、用户失败次数和账号锁定基础上,新增内存级登录节流,按用户名与来源 IP 统计失败次数;即使用户名不存在,连续失败也会触发 `429 AUTH_LOGIN_THROTTLED`,避免撞库/爆破时泄露账号存在性。新增运行时配置 `AUTH_LOGIN_THROTTLE_MAX_FAILURES` 和 `AUTH_LOGIN_THROTTLE_WINDOW_SECONDS`。
|
||||
- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`apps/web/src/styles.css`、`apps/api/src/shared/config.ts`、`apps/api/src/modules/auth/auth.service.ts`、`apps/api/src/modules/auth/auth.service.spec.ts`
|
||||
- 安装软件及固定版本:未安装新软件;继续使用 Node.js/pnpm、NestJS、Vite、Vitest、Prisma 既有版本。
|
||||
- 执行的关键命令:本地 `corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`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`、`corepack pnpm@10.33.0 exec vitest run --maxWorkers=1`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 build`
|
||||
- 验证结果:lint 通过;typecheck 通过;认证单测/e2e 6 条通过;全量 Vitest 使用 `--maxWorkers=1` 后 20 个测试文件、69 条测试全部通过;Prisma schema validate 通过;workspace build 通过。首次全量并行测试曾出现多个 Nest e2e `beforeAll` 超时,改为单 worker 后全部通过,判断为本地并行资源/初始化竞争导致的假失败。
|
||||
- 回滚方式:恢复上述 7 个文件到本次修改前版本即可;无数据库 schema 变更、无服务器写操作、无 Redis/OpenSIPS/RTPEngine 配置变更。若已发布到 B,可按 `docs/S30_RELEASE_BACKUP_RUNBOOK.md` 使用上一 release 切回并重启 API/Web 相关服务。
|
||||
- 未解决问题:本次只完成本地代码修改,尚未发布到 B 当前冻结 release,也未做浏览器视觉截图或 A/B/T 端到端验收;登录节流为单进程内存级,足以覆盖当前单 API 进程开发环境,后续多 API 实例或生产环境建议迁移到 Redis/网关级限流以跨进程共享计数。
|
||||
- 下一任务:等待下一个二期需求;如需上线本变更,应按 S30 发布流程创建新 B release 并做 preflight/登录冒烟。
|
||||
|
||||
### 2026-06-22 10:35 - S30 备份、Runbook、灰度与上线完成
|
||||
|
||||
- 状态:已完成
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# LisgloSIPS V2 项目设计与实施文档
|
||||
|
||||
> 中文名:聆界SIP管理平台
|
||||
> 文档版本:V2.0
|
||||
> 文档版本:V2.2
|
||||
> 编制日期:2026-06-20
|
||||
> 文档状态:实施基线草案
|
||||
> 文档状态:S30 后二期需求变更持续补充
|
||||
> 依据:服务器架构图、`SOFTSWITCH_PLATFORM_DESIGN_V1.md`、当前 React Web Demo、`OPENSIPS_INSTALL_NOTES.md`
|
||||
|
||||
## 1. 文档目的
|
||||
@@ -21,12 +21,14 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上
|
||||
| 菜单 | V2 实施内容 |
|
||||
| --- | --- |
|
||||
| 概览 Dashboard | 通话、接通率、消费、成本、毛利、注册、节点、异常网关、质检指标 |
|
||||
| 客户管理 | 客户新增、编辑、启停、余额、授信、充值、网关数量 |
|
||||
| 客户网关管理 | IP/SIP 注册认证、启停、多个策略、优先级、主被叫匹配、线路组绑定 |
|
||||
| 充值记录 | 客户充值、供应商充值、余额前后值、操作人、备注 |
|
||||
| 供应商管理 | 供应商账户、余额、授信、充值 |
|
||||
| 落地网关管理 | 认证、并发、CPS、禁呼时段、编码、号码转换、计费周期、费率、启停 |
|
||||
| 落地线路组 | 线路组、组内网关、优先级、并发汇总 |
|
||||
| 客户管理 | 客户新增、编辑、启停、余额、授信、充值、网关数量、受控删除 |
|
||||
| 客户网关管理 | IP/SIP 注册认证、启停、删除、多个策略、优先级、主被叫匹配、线路组绑定 |
|
||||
| 充值记录 | 客户充值、供应商充值、余额前后值、操作人、备注;仅记录页面人工充值,不记录话单消费扣费 |
|
||||
| 供应商管理 | 供应商账户、余额、授信、充值、落地网关数量、受控删除 |
|
||||
| 落地网关管理 | 认证、并发、CPS、禁呼时段、编码、号码转换、计费周期、费率、启停、删除 |
|
||||
| 落地线路组 | 线路组、组内网关、优先级、并发汇总、使用客户网关数量、受控删除 |
|
||||
| 号码库 | 手机号码库、城市区号、运营商号码段规则;为话单归属地/运营商和落地网关屏蔽地区提供基础数据 |
|
||||
| 当前通话 | OpenSIPS Dialog 实时列表、呼叫方 IP、落地 IP、自动刷新、强制挂断 |
|
||||
| 话单中心 | 话单查询、挂断原因、费用、录音、信令入口、详情 |
|
||||
| 质检中心 | 抽检规则、录音列表、连续播放、自动播放、问题标注、评分 |
|
||||
| 用户管理 | 用户新增、编辑、启停、重置密码、角色绑定 |
|
||||
@@ -35,7 +37,7 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上
|
||||
|
||||
### 2.2 延期页面
|
||||
|
||||
以下菜单在 Web Demo 中标记为“待设计”,本期不开发其业务页面:
|
||||
以下菜单在 Web Demo 中曾标记为“待设计”,S30 后二期变更要求在导航中隐藏,不向运营用户展示入口:
|
||||
|
||||
- 费率与计费
|
||||
- SIP 运维
|
||||
@@ -299,7 +301,8 @@ INVITE
|
||||
-> Redis 查询客户、客户网关、状态、余额门槛
|
||||
-> Redis 查询客户网关策略(主叫条件 AND 被叫条件,按优先级)
|
||||
-> 获得落地线路组
|
||||
-> 按组内优先级、启用状态、并发/CPS 选择落地网关
|
||||
-> 识别被叫号码归属地级市和运营商
|
||||
-> 按组内优先级、启用状态、并发/CPS、屏蔽地区选择落地网关
|
||||
-> 执行主被叫前缀转换、时段和编码限制
|
||||
-> RTPEngine offer/answer
|
||||
-> 转发到供应商
|
||||
@@ -344,6 +347,10 @@ Redis Lua 脚本一次完成:
|
||||
"source_ip": "10.10.1.11",
|
||||
"caller": "02160010001",
|
||||
"callee": "13800138000",
|
||||
"callee_city_code": "310000",
|
||||
"callee_city_name": "上海",
|
||||
"callee_province_name": "上海",
|
||||
"callee_operator": "MOBILE",
|
||||
"vendor_id": "V1001",
|
||||
"vendor_gateway_id": "VGW-001",
|
||||
"line_group_id": "LG-001",
|
||||
@@ -412,11 +419,15 @@ customer_recharges
|
||||
vendors
|
||||
vendor_recharges
|
||||
vendor_gateways
|
||||
vendor_gateway_blocked_regions
|
||||
vendor_gateway_forbidden_periods
|
||||
vendor_gateway_codecs
|
||||
vendor_gateway_prefix_rules
|
||||
landing_line_groups
|
||||
landing_line_group_items
|
||||
geo_cities
|
||||
phone_number_segments
|
||||
carrier_prefix_rules
|
||||
raw_cdrs
|
||||
rated_cdrs
|
||||
recordings
|
||||
@@ -440,6 +451,7 @@ idempotency_keys
|
||||
- 网关策略唯一索引至少包含 `gateway_id + priority`。
|
||||
- 线路组成员唯一索引包含 `line_group_id + vendor_gateway_id`。
|
||||
- CDR 使用 `event_id` 唯一索引。
|
||||
- CDR 需要保存被叫号码解析出的地级市、省份和运营商快照,避免后续号码库更新影响历史话单解释。
|
||||
- 所有管理表包含 `created_at`、`updated_at`、`created_by`、`updated_by` 和乐观锁版本号。
|
||||
- 时间存 UTC;API 返回 ISO 8601。
|
||||
- 删除优先软删除;充值、CDR、审计日志不允许物理删除。
|
||||
@@ -1135,7 +1147,7 @@ V2 首期仅支持落地网关上的:
|
||||
|
||||
- 按菜单逐页替换 Mock;每次只接入一个领域。
|
||||
- 增加加载、空状态、错误、权限隐藏和并发更新提示。
|
||||
- 待设计菜单继续保留标识,不接临时 API。
|
||||
- 待设计菜单不接临时 API;S30 后二期变更要求导航中隐藏这些入口。
|
||||
|
||||
**S28 三机端到端联调**
|
||||
|
||||
@@ -1152,7 +1164,267 @@ V2 首期仅支持落地网关上的:
|
||||
- 完成恢复演练、上线/回滚脚本和灰度。
|
||||
- 上线前冻结版本和数据库迁移,输出最终验收报告。
|
||||
|
||||
### 25.5 Codex 新会话启动模板
|
||||
### 25.5 S30 后二期需求变更
|
||||
|
||||
S30 完成本地 KVM A/B/T 闭环后,用户基于实际试用继续提出二期变更。二期变更不重做 S00-S30 基线,按“在既有架构上最小增量、可验证、可回滚”的原则落地。当前本地冻结基线仍为 `s28-v2-20260621220924`,B 当前运行 release 为 `s33-active-calls-20260623094000`,多次二期前端/API/通信配置小改覆盖在该 release 上。阿里云迁移仍不得自动开始,迁移前必须按 S30 Runbook 重新演练。
|
||||
|
||||
#### 25.5.1 登录与验证码
|
||||
|
||||
- 登录页必须使用后端图形验证码,验证码刷新失败时应有明确错误提示,登录失败后必须刷新验证码。
|
||||
- 登录防爆破、Session/Refresh Token、Cookie 安全配置仍归属 S09/S10 安全基线。
|
||||
- 禁止在文档、日志或页面中输出明文密码;初始账号、服务器凭据只允许通过私密目录和受控流程读取。
|
||||
|
||||
#### 25.5.2 当前通话与强制挂断
|
||||
|
||||
新增“当前通话”能力,定位为实时运维视图,不作为 CDR 或计费真相源。
|
||||
|
||||
- Web 在“业务”菜单下提供“当前通话”页面,展示 OpenSIPS 当前 Dialog 列表。
|
||||
- B API 提供:
|
||||
- `GET /api/v2/active-calls`
|
||||
- `POST /api/v2/active-calls/:id/hangup`
|
||||
- 权限新增:
|
||||
- `active_calls.view`
|
||||
- `active_calls.manage`
|
||||
- 超级管理员具备查看和挂断权限;技术运维具备查看和挂断权限;运营管理员默认仅查看。
|
||||
- API 通过 B 上的受限 SSH key 调用 A 上的 forced-command `/usr/local/sbin/lisglosips-call-control`,只允许 `dlg_list` 和 `dlg_end_dlg`。OpenSIPS MI HTTP 仍只绑定 `127.0.0.1:8888`。
|
||||
- 当前通话列表字段至少包括:
|
||||
- Dialog ID / Call-ID
|
||||
- 主叫、被叫
|
||||
- 呼叫方 IP
|
||||
- 落地 IP
|
||||
- 状态、开始时间、持续时长
|
||||
- 强制挂断操作
|
||||
- 呼叫方 IP 和落地 IP 的解析优先级为 SIP Contact、SDP `c=IN IP4/IP6`、SIP URI、bind 地址兜底。生产上若落地网关不提供 Contact 或 SDP 连接地址,落地 IP 可能退回为 OpenSIPS 本地地址;后续可在路由侧写入 Dialog 变量提高精度。
|
||||
- 强制挂断必须弹出自研确认弹窗;操作必须进入 RBAC 和审计。
|
||||
- OpenSIPS `dlg_end_dlg` 可能返回 `500 Operation failed`,但 Dialog 进入 state `5` 并随后清空时,API 可按“已受理/处理中”处理。
|
||||
- 当前通话页面默认支持 5 秒自动刷新,并保留手动刷新。
|
||||
|
||||
#### 25.5.3 列表计数、删除和启停确认
|
||||
|
||||
二期增加删除能力时,优先使用软删除和引用保护,不允许破坏热路径配置一致性。
|
||||
|
||||
- 客户管理:
|
||||
- 列表显示客户网关数。
|
||||
- 删除客户前必须确认。
|
||||
- 若客户关联客户网关数大于 0,则拒绝删除。
|
||||
- 客户网关管理:
|
||||
- 支持删除,删除前必须确认。
|
||||
- 删除客户网关时,其关联路由策略应同步停止生效或软删除,并发布配置 Outbox。
|
||||
- 启用/禁用前必须确认。
|
||||
- 供应商管理:
|
||||
- 列表显示落地网关数。
|
||||
- 删除供应商前必须确认。
|
||||
- 若供应商关联落地网关数大于 0,则拒绝删除。
|
||||
- 落地网关管理:
|
||||
- 支持删除,删除前必须确认。
|
||||
- 启用/禁用前必须确认。
|
||||
- 若落地网关仍被未删除落地线路组引用,默认拒绝删除,避免线路组热配置引用已删除网关。若未来要支持自动从线路组移除,需单独设计成员优先级重排和配置发布语义。
|
||||
- 落地线路组:
|
||||
- 列表显示正在使用该线路组的去重客户网关数。
|
||||
- 删除线路组前必须确认。
|
||||
- 若有客户网关策略正在使用该线路组,则拒绝删除。
|
||||
- 所有删除按钮使用红色警示态;所有确认弹窗使用自研 UI,不再使用浏览器原生 `window.confirm`。
|
||||
|
||||
#### 25.5.4 充值记录与计费流水语义
|
||||
|
||||
充值记录页面只展示人工充值,不承载话单消费扣费记录。
|
||||
|
||||
- 页面人工充值写入 `customer_recharges` / `vendor_recharges` 或等价充值流水。
|
||||
- CDR Worker 的话单消费扣费只写 raw/rated CDR 和余额扣减结果,不再写客户充值记录。
|
||||
- 充值记录 API 需要过滤历史由 `worker-cdr`、`cdr:`、`CDR_CHARGE:` 等来源产生的消费类流水,避免在“充值记录”中混入话单扣费。
|
||||
- Web 充值成功后只局部更新对应余额和充值记录列表,不应触发整页 `refreshApi()`,避免供应商充值后长时间停留在“拉取 API”。
|
||||
- 话单消费、客户费用、供应商成本仍以 CDR/rated CDR 和余额变更为准。
|
||||
|
||||
#### 25.5.5 动态路由、CDR 和配置发布补齐
|
||||
|
||||
完整新建链路测试暴露出 S28 早期热路径仍有硬编码成功 CDR 和落地网关的问题。二期要求热路径使用配置发布结果动态选择路由。
|
||||
|
||||
- OpenSIPS Lua 热路径按客户网关策略匹配线路组,并选择线路组内首个启用落地网关。
|
||||
- OpenSIPS 成功 CDR 从 Dialog 变量写入动态 customer/gateway/policy/vendorGateway/lineGroup,不再写硬编码对象。
|
||||
- B 必须提供 `config-publisher.env` 并保持 `lisglosips@config-publisher` active。
|
||||
- CDR Worker 对 `config_version` 等大数版本字段必须避免 MySQL INT 越界。
|
||||
- 端到端验收流程应覆盖:
|
||||
1. 新建供应商
|
||||
2. 新建落地网关
|
||||
3. 新建落地线路组
|
||||
4. 新建客户
|
||||
5. 新建客户网关
|
||||
6. 配置客户网关策略指向新线路组
|
||||
7. 给客户充值
|
||||
8. 给供应商充值
|
||||
9. 打通电话
|
||||
10. 当前通话页面查看并强制挂断
|
||||
11. 核对 raw/rated CDR、客户余额、供应商成本和审计记录
|
||||
|
||||
#### 25.5.6 前端信息架构和交互细化
|
||||
|
||||
二期 UI 调整遵循当前控制台风格,不引入新的视觉体系。
|
||||
|
||||
- “待设计”菜单在导航中隐藏,包括费率与计费、SIP 运维、监控告警、系统设置。底层最小计费、HOMER、Prometheus/Grafana、配置文件能力仍按 S00-S30 保留。
|
||||
- 客户管理、客户网关管理、充值记录、供应商管理、落地网关管理等页面中,ID 和名称类列宽应适当收窄,长文本使用省略号,避免挤占金额、状态和操作列。
|
||||
- 话单详情抽屉应分区展示:
|
||||
- 顶部主叫到被叫概览
|
||||
- 通话摘要:时长、挂断原因、客户费用、成本费用
|
||||
- 链路信息:客户网关、呼叫 IP、落地网关、线路 IP
|
||||
- 时间轴:呼叫、接通、结束时间
|
||||
- 录音播放和信令查看入口
|
||||
- 当前通话、删除、启停、重置密码、策略删除、质检规则删除等确认场景统一使用自研确认弹窗。
|
||||
|
||||
#### 25.5.7 10 路虚拟呼叫测试工具
|
||||
|
||||
为观察当前通话页面的实时变化,二期新增 T 侧虚拟呼叫编排工具。
|
||||
|
||||
- 工具路径:`/opt/lisglosips-s40/lisglosips-s40-virtual-calls.py`,源码纳入 `infra/server-t/s40/`。
|
||||
- 默认测试参数:
|
||||
- 总计 10 路虚拟呼叫。
|
||||
- 每隔 15 秒发起 1 路。
|
||||
- 前 5 路先返回 `180 Ringing`,45 秒后返回 `200 OK`,通话保持 600 秒后 BYE。
|
||||
- 后 5 路先返回 `180 Ringing`,70 秒后返回 `480 Temporarily Unavailable`。
|
||||
- 使用当前本地测试策略的主叫 `s36-1001` 和被叫 `13800136036`,按 Call-ID 序号区分接通/不接通场景。
|
||||
- T 上旧 S28 UAS 占用 `100.93.185.30:50620`,完整测试时需要临时停旧 UAS,由 S40 UAS 接管;测试结束或提前停止后必须恢复旧 S28 UAS。
|
||||
- 首次测试暴露 A `fr_inv_timeout=30` 会在 45 秒接通前返回 `408 Request Timeout`。为了支持 45 秒接通和 70 秒未接场景,本地 KVM A 已将 `/etc/opensips/opensips.cfg` 中 `modparam("tm", "fr_inv_timeout", 30)` 临时调整为 `95` 并重启 OpenSIPS。该调整属于测试窗口配置,迁移生产前必须重新评估运营侧真实振铃超时策略。
|
||||
- 停止完整测试命令:
|
||||
|
||||
```bash
|
||||
sudo pkill -f /opt/lisglosips-s40/lisglosips-s40-virtual-calls.py
|
||||
```
|
||||
|
||||
- 若 T 旧 UAS 未恢复,可执行:
|
||||
|
||||
```bash
|
||||
sudo nohup runuser -u nobody -- /usr/bin/python3 /opt/lisglosips-s28/lisglosips-s28-sip.py uas --host 100.93.185.30 --port 50620 >/tmp/lisglosips-s28-uas.log 2>&1 &
|
||||
```
|
||||
|
||||
#### 25.5.8 二期变更的回归要求
|
||||
|
||||
每次二期变更完成后至少执行:
|
||||
|
||||
- 相关单元测试或 e2e 测试。
|
||||
- `corepack pnpm@10.33.0 typecheck`
|
||||
- `corepack pnpm@10.33.0 lint`
|
||||
- `corepack pnpm@10.33.0 build`
|
||||
- 若涉及 A/B/T 三机链路,则执行端到端验收并按 Call-ID 记录结果。
|
||||
- 若涉及 OpenSIPS 配置,必须先备份、执行 `opensips -C -f /etc/opensips/opensips.cfg`,再重启或 reload,并记录回滚点。
|
||||
- 若涉及 Web 发布,必须备份 B 当前 release 的 `apps/web/dist`,覆盖后执行 `nginx -t` 和 reload。
|
||||
- 每次完成后更新 `IMPLEMENTATION_STATUS.md`,写明修改内容、验证结果、回滚方式和遗留问题。
|
||||
|
||||
#### 25.5.9 号码库、归属地运营商识别与屏蔽地区路由
|
||||
|
||||
新增“号码库”菜单,定位为呼叫路由和话单归属地的基础数据管理,不属于普通费率页面。菜单包含四个 Tab:
|
||||
|
||||
- 地级市字典:维护稳定的地级市编码、省份、地级市名称、状态和生效期,供手机号码库、城市区号、落地网关屏蔽地区和话单快照统一引用。
|
||||
- 手机号码库:通过手机号前 7 位号段匹配地级市,预计约 80 万条号段。
|
||||
- 城市区号:保存全国固话区号,例如 `0551` 对应合肥、`021` 对应上海,维度到地级市。
|
||||
- 运营商号码段规则:通过手机号前 3-4 位匹配归属运营商,用于快速判断中国移动/中国联通/中国电信/广电/虚拟运营商等。
|
||||
|
||||
是否增加地级市字典:需要增加。原因是手机号码库和城市区号都要统一落到地级市维度,落地网关屏蔽地区也需要引用稳定地区编码;如果只在号段表中保存城市文本,会造成同名、改名、直辖市、省市归属和历史变更难以维护。建议新增 `geo_cities` 字典,字段至少包括:
|
||||
|
||||
- `code`:行政区划码或项目稳定编码,作为主键或唯一键。
|
||||
- `province_code`、`province_name`
|
||||
- `city_code`、`city_name`
|
||||
- `city_level`:地级市、直辖市、地区、自治州等。
|
||||
- `status`、`effective_from`、`effective_to`
|
||||
- 审计列和软删除列。
|
||||
|
||||
号码库数据模型建议:
|
||||
|
||||
- `phone_number_segments`
|
||||
- `segment7`:手机号前 7 位,唯一。
|
||||
- `city_code`:关联 `geo_cities`。
|
||||
- `province_name`、`city_name` 可冗余快照,便于导入校验和快速展示。
|
||||
- `carrier` 可选;若和运营商规则冲突,运营商以 `carrier_prefix_rules` 为准,并记录数据质量告警。
|
||||
- `source`、`batch_id`、`effective_from`、`effective_to`、`updated_at`。
|
||||
- `carrier_prefix_rules`
|
||||
- `prefix`:手机号前 3-4 位,唯一或按生效期唯一。
|
||||
- `carrier`:`MOBILE`、`UNICOM`、`TELECOM`、`BROADCAST`、`MVNO`、`UNKNOWN` 等枚举。
|
||||
- `priority`:前缀重叠时按最长前缀和优先级匹配。
|
||||
- `effective_from`、`effective_to`。
|
||||
- `phone_area_codes`
|
||||
- `area_code`:固话区号,例如 `021`、`0551`。
|
||||
- `city_code`:关联 `geo_cities`。
|
||||
- `province_name`、`city_name` 展示冗余。
|
||||
- `vendor_gateway_blocked_regions`
|
||||
- `vendor_gateway_id`
|
||||
- `city_code`,必要时支持省级屏蔽可通过 `region_scope=PROVINCE/CITY` 建模。
|
||||
- `created_at`、`created_by`。
|
||||
|
||||
匹配规则:
|
||||
|
||||
- 手机号归属地:优先取规范化后的被叫号码前 7 位匹配 `phone_number_segments.segment7`。
|
||||
- 手机号运营商:按被叫号码前 4 位、前 3 位依次匹配 `carrier_prefix_rules`,优先最长前缀。
|
||||
- 固话归属地:对被叫号码做号码规范化后匹配城市区号;区号需要支持 `0xx`、`0xxx`,并注意去掉外呼前缀、国家码 `+86/0086` 后再判断。
|
||||
- 无法识别时,城市和运营商写 `UNKNOWN`,呼叫不应仅因号码库缺失被拒绝,除非客户或全局策略明确要求。
|
||||
|
||||
每通话单必须保存号码识别快照:
|
||||
|
||||
- `callee_city_code`
|
||||
- `callee_city_name`
|
||||
- `callee_province_name`
|
||||
- `callee_operator`
|
||||
- 可选保存 `callee_number_type`:`MOBILE`、`LANDLINE`、`INTERNATIONAL`、`UNKNOWN`。
|
||||
|
||||
落地网关屏蔽地区路由要求:
|
||||
|
||||
- Config Publisher 发布线路组时,需要把线路组成员落地网关的屏蔽地区一起写入 Redis 热路径配置。
|
||||
- OpenSIPS/Lua 在选中客户网关策略和线路组后,先识别被叫地级市,再按线路组成员优先级选择落地网关。
|
||||
- 若某落地网关屏蔽该地级市或其所在省份,则跳过该网关,继续尝试同一线路组中的下一落地网关。
|
||||
- 若同一线路组所有可用落地网关都被屏蔽或不可用,返回明确失败原因,例如 `NO_VENDOR_ROUTE_REGION_BLOCKED`,并写入失败 CDR。
|
||||
- 成功 CDR 需要写入最终实际选中的 `vendor_gateway_id` 和号码识别快照;不能只记录第一次被跳过的网关。
|
||||
- 当前 V2 单节点 OpenSIPS 热路径必须保持可预测和低延迟,80 万手机号段不应在每通电话中扫描 MySQL。推荐路径是 B 侧导入 MySQL 后,由 Config Publisher 生成 Redis 查找结构:
|
||||
- `cfg:v:{version}:phone_segment:{segment7}` -> city/operator 快照。
|
||||
- `cfg:v:{version}:area_code:{areaCode}` -> city 快照。
|
||||
- `cfg:v:{version}:carrier_prefix:{prefix}` -> carrier。
|
||||
- `cfg:v:{version}:vendor_gateway:{id}:blocked_regions` -> city/province set 或紧凑 JSON。
|
||||
|
||||
实施拆分建议:
|
||||
|
||||
1. 数据建模与迁移:新增地级市字典、手机号码库、城市区号、运营商前缀规则、落地网关屏蔽地区和 raw CDR 号码识别字段。此步骤涉及数据库 schema migration,执行前必须备份 B MySQL,说明回滚点;80 万号段导入需要单独评估索引和迁移时间。
|
||||
2. 导入与管理 API:实现号码库导入、分页查询、按号段/区号/城市检索、批次校验、重复号段冲突报告;大批量导入应走文件/后台任务,不建议通过普通 JSON 表单一次提交。
|
||||
3. Web 菜单:新增“号码库”菜单和四个 Tab(地级市字典、手机号码库、城市区号、运营商号码段规则),先支持查询、导入结果查看和基础维护;超大号段列表必须服务端分页和筛选,不做前端全量加载。
|
||||
4. Config Publisher 与 Redis 热路径:把号码库快照、运营商规则、城市区号和落地网关屏蔽地区发布到版本化 Redis key;保留上一版本用于一键回滚。
|
||||
5. OpenSIPS/Lua 路由:在当前动态路由基础上增加号码规范化、地级市/运营商解析、屏蔽地区跳过下一落地网关、失败原因写入;改动前必须备份 A `/etc/opensips/opensips.cfg` 和 Lua,执行 `opensips -C -f` 后再重启。
|
||||
6. CDR Stream 与 Worker:升级 CDR event schema,解析并入库归属地/运营商字段;保持向后兼容旧 schema,避免旧 Stream 或 pending 消息死信。
|
||||
7. 话单中心展示:列表/详情增加地级市、运营商展示和筛选;历史无字段话单显示 `UNKNOWN` 或 `-`。
|
||||
8. 端到端验收:构造同一线路组两个落地网关,其中第一个屏蔽目标地级市,第二个允许;发起测试呼叫,验证当前通话/成功 CDR 使用第二个网关,并保存正确地级市和运营商;再验证全部网关屏蔽时失败 CDR 原因为 `NO_VENDOR_ROUTE_REGION_BLOCKED`。
|
||||
|
||||
风险与约束:
|
||||
|
||||
- 80 万手机号段属于大批量主数据,导入、索引、Redis 发布和回滚都需要独立 Runbook,不能夹在普通前端小改中发布。
|
||||
- 手机号段和行政区划会变更,必须保留数据来源、批次和生效期,避免“更新号码库”改变历史 CDR 解释。
|
||||
- Redis 热路径内 JSON 字符串解析能力有限,复杂匹配逻辑应提前在 Config Publisher 生成适合 Lua 快速读取的结构。
|
||||
- 地区屏蔽属于路由策略,必须与并发/CPS、禁呼时段、编码限制的优先级关系固定下来:推荐先过滤状态/禁呼/地区屏蔽,再做并发/CPS 占用。
|
||||
|
||||
本地实施状态:
|
||||
|
||||
- 2026-06-24 已完成第 1、2 步本地代码基线:Prisma Schema、迁移文件、号码库 API、RBAC/seed、审计和后端测试已完成;尚未发布到 B,尚未执行 MySQL migration,尚未导入真实 80 万号段。
|
||||
- 2026-06-24 已完成第 3 步 Web 本地代码基线:运营菜单新增“号码库”,页面包含地级市字典、手机号码库、城市区号、运营商号码段规则四个 Tab;每个 Tab 支持服务端分页查询入口、筛选、刷新和批量 JSON 导入弹窗。该导入弹窗只适合小批校验和后台接口联调,真实 80 万号段仍需按后续导入任务/Runbook 走文件或后台任务。
|
||||
- 2026-06-24 已完成第 4、5 步本地代码基线:Config Publisher 会把地级市、手机 7 位号段、固话区号、运营商前缀和落地网关屏蔽地区发布为版本化 Redis key;号码库导入会写入 `number_library_config` outbox 触发新快照。OpenSIPS S28 Lua 热路径会解析被叫号码,命中屏蔽城市/省份时跳过当前落地网关并尝试同一线路组下一网关,全部被屏蔽时返回 `NO_VENDOR_ROUTE_REGION_BLOCKED`;成功/失败 CDR Stream 已携带归属地和运营商字段。当前仅完成本地文件,尚未发布 B/A;发布 A 前必须重新加载 Lua、替换 `/etc/opensips/opensips.cfg` 前备份,并在 A 上执行 `opensips -C -f /etc/opensips/opensips.cfg` 后再重启。
|
||||
- 2026-06-24 已完成第 6、7 步本地代码基线:CDR Stream 解析兼容新增 `callee_city_code`、`callee_city_name`、`callee_province_name`、`callee_operator`、`callee_number_type`,旧 Stream 消息缺字段时仍可处理;CDR Worker 会把这些字段写入 `raw_cdrs` 号码识别快照列。新增 `GET /api/v2/cdrs` 与 `GET /api/v2/cdrs/:id`,复用 `cdr.view` 权限,支持主叫、被叫、客户网关、落地网关、地级市代码和运营商筛选。Web 话单中心已接入真实 CDR API,列表和详情展示地级市、运营商、号码类型、SIP 状态码和费用字段。
|
||||
|
||||
第 1、2 步已完成本地代码基线:
|
||||
|
||||
- Prisma Schema 和迁移新增:
|
||||
- `geo_cities`
|
||||
- `phone_number_segments`
|
||||
- `phone_area_codes`
|
||||
- `carrier_prefix_rules`
|
||||
- `vendor_gateway_blocked_regions`
|
||||
- `raw_cdrs` 被叫地级市、省份、运营商、号码类型快照字段
|
||||
- API 模块新增 `NumberLibraryModule`:
|
||||
- `GET /api/v2/number-library/cities`
|
||||
- `POST /api/v2/number-library/cities/import`
|
||||
- `GET /api/v2/number-library/phone-segments`
|
||||
- `POST /api/v2/number-library/phone-segments/import`
|
||||
- `GET /api/v2/number-library/area-codes`
|
||||
- `POST /api/v2/number-library/area-codes/import`
|
||||
- `GET /api/v2/number-library/carrier-prefix-rules`
|
||||
- `POST /api/v2/number-library/carrier-prefix-rules/import`
|
||||
- 权限新增:
|
||||
- `number_library.view`
|
||||
- `number_library.manage`
|
||||
- 当前导入接口采用单批 `items` upsert,单次限制 1000 条,适合脚本/后台任务分批调用。80 万号段的真实文件导入、进度表、失败明细和 Redis 发布仍属于后续步骤,不在本次第 1、2 步中直接执行。
|
||||
|
||||
### 25.6 Codex 新会话启动模板
|
||||
|
||||
```text
|
||||
请先读取 SOFTSWITCH_PLATFORM_DESIGN_V2.md 和 IMPLEMENTATION_STATUS.md,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Inject, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { RequirePermissions } from '../security/security.metadata.js';
|
||||
import { ActiveCallsService } from './active-calls.service.js';
|
||||
|
||||
@ApiTags('active-calls')
|
||||
@Controller('active-calls')
|
||||
export class ActiveCallsController {
|
||||
constructor(@Inject(ActiveCallsService) private readonly activeCallsService: ActiveCallsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('active_calls.view')
|
||||
list() {
|
||||
return this.activeCallsService.list();
|
||||
}
|
||||
|
||||
@Post(':id/hangup')
|
||||
@RequirePermissions('active_calls.manage')
|
||||
@AuditAction({ module: 'active_calls', action: 'hangup', objectType: 'dialog', objectIdParam: 'id' })
|
||||
hangup(@Param('id') id: string) {
|
||||
return this.activeCallsService.hangup(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ActiveCallsController } from './active-calls.controller.js';
|
||||
import { ActiveCallsService } from './active-calls.service.js';
|
||||
import { OpenSipsMiClient } from './opensips-mi.client.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ActiveCallsController],
|
||||
providers: [ActiveCallsService, OpenSipsMiClient]
|
||||
})
|
||||
export class ActiveCallsModule {}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { BadGatewayException, BadRequestException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ActiveCallsService, normalizeDialogs } from './active-calls.service.js';
|
||||
import type { OpenSipsMiClient } from './opensips-mi.client.js';
|
||||
|
||||
describe('active calls service', () => {
|
||||
it('normalizes OpenSIPS dialog list payloads', () => {
|
||||
const calls = normalizeDialogs(
|
||||
{
|
||||
Dialogs: [
|
||||
{
|
||||
ID: '6ae.4b38d013',
|
||||
callid: 'call-1@lisglosips-t',
|
||||
from_tag: 'from-a',
|
||||
to_tag: 'to-b',
|
||||
from_uri: 'sip:1001@s21.lisglosips.test',
|
||||
to_uri: 'sip:2001@s21.lisglosips.test',
|
||||
state: 'confirmed',
|
||||
start_time: '1782100000'
|
||||
}
|
||||
]
|
||||
},
|
||||
new Date('2026-06-22T08:00:10.000Z')
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toMatchObject({
|
||||
id: '6ae.4b38d013',
|
||||
callId: 'call-1@lisglosips-t',
|
||||
fromTag: 'from-a',
|
||||
toTag: 'to-b',
|
||||
caller: 'sip:1001@s21.lisglosips.test',
|
||||
callee: 'sip:2001@s21.lisglosips.test',
|
||||
state: 'confirmed'
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts caller and landing IPs from dialog contacts and SDP', () => {
|
||||
const calls = normalizeDialogs({
|
||||
Dialogs: [
|
||||
{
|
||||
ID: 'fd4.a9a0c987',
|
||||
callid: 'call-2@lisglosips-t',
|
||||
from_uri: 'sip:s28-ip-1001@s28.customer.local',
|
||||
to_uri: 'sip:13800138000@100.90.90.90:15060',
|
||||
caller_contact: 'sip:s28-ip-1001@100.93.185.30:47200',
|
||||
caller_sdp: 'v=0\r\nc=IN IP4 100.93.185.30\r\n',
|
||||
CALLEES: [
|
||||
{
|
||||
callee_sdp: 'v=0\r\nc=IN IP4 192.0.2.80\r\n'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
callerIp: '100.93.185.30',
|
||||
landingIp: '192.0.2.80'
|
||||
});
|
||||
});
|
||||
|
||||
it('requests hangup for safe dialog identifiers', async () => {
|
||||
const miClient = {
|
||||
endDialog: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OpenSipsMiClient;
|
||||
const service = new ActiveCallsService(miClient);
|
||||
|
||||
await expect(service.hangup('call-1%40lisglosips-t')).resolves.toMatchObject({
|
||||
dialogId: 'call-1@lisglosips-t',
|
||||
status: 'HANGUP_REQUESTED'
|
||||
});
|
||||
expect(miClient.endDialog).toHaveBeenCalledWith('call-1@lisglosips-t');
|
||||
});
|
||||
|
||||
it('accepts OpenSIPS operation-failed hangup when the dialog is terminating', async () => {
|
||||
const miClient = {
|
||||
endDialog: vi.fn().mockRejectedValue(
|
||||
new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_ERROR',
|
||||
message: 'Operation failed',
|
||||
detail: { code: 500, message: 'Operation failed' }
|
||||
})
|
||||
),
|
||||
listDialogs: vi.fn().mockResolvedValue({
|
||||
Dialogs: [{ ID: '6ae.4b38d013', callid: 'call-1@lisglosips-t', state: 5 }]
|
||||
})
|
||||
} as unknown as OpenSipsMiClient;
|
||||
const service = new ActiveCallsService(miClient);
|
||||
|
||||
await expect(service.hangup('6ae.4b38d013')).resolves.toMatchObject({
|
||||
dialogId: '6ae.4b38d013',
|
||||
status: 'HANGUP_IN_PROGRESS',
|
||||
state: '5'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsafe dialog identifiers', async () => {
|
||||
const service = new ActiveCallsService({ endDialog: vi.fn() } as unknown as OpenSipsMiClient);
|
||||
|
||||
await expect(service.hangup('../../etc/passwd')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { OpenSipsMiClient } from './opensips-mi.client.js';
|
||||
|
||||
export interface ActiveCallSummary {
|
||||
id: string;
|
||||
callId: string;
|
||||
fromTag: string | null;
|
||||
toTag: string | null;
|
||||
caller: string | null;
|
||||
callee: string | null;
|
||||
state: string | null;
|
||||
startedAt: string | null;
|
||||
durationSec: number | null;
|
||||
lifetimeSec: number | null;
|
||||
callerIp: string | null;
|
||||
landingIp: string | null;
|
||||
callerContact: string | null;
|
||||
calleeContact: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SAFE_DIALOG_ID = /^[A-Za-z0-9@._:%+\-=]{1,220}$/;
|
||||
|
||||
@Injectable()
|
||||
export class ActiveCallsService {
|
||||
constructor(@Inject(OpenSipsMiClient) private readonly miClient: OpenSipsMiClient) {}
|
||||
|
||||
async list(now = new Date()) {
|
||||
const result = await this.miClient.listDialogs();
|
||||
const calls = normalizeDialogs(result, now);
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
source: 'opensips-mi',
|
||||
total: calls.length,
|
||||
items: calls
|
||||
};
|
||||
}
|
||||
|
||||
async hangup(dialogId: string) {
|
||||
const normalized = this.dialogId(dialogId);
|
||||
try {
|
||||
const result = await this.miClient.endDialog(normalized);
|
||||
return {
|
||||
dialogId: normalized,
|
||||
status: 'HANGUP_REQUESTED',
|
||||
source: 'opensips-mi',
|
||||
result
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isOpenSipsOperationFailed(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sleep(800);
|
||||
const calls = normalizeDialogs(await this.miClient.listDialogs());
|
||||
const current = calls.find((call) => call.id === normalized || call.callId === normalized);
|
||||
if (!current) {
|
||||
return {
|
||||
dialogId: normalized,
|
||||
status: 'HANGUP_CONFIRMED',
|
||||
source: 'opensips-mi'
|
||||
};
|
||||
}
|
||||
if (current.state === '5') {
|
||||
return {
|
||||
dialogId: normalized,
|
||||
status: 'HANGUP_IN_PROGRESS',
|
||||
source: 'opensips-mi',
|
||||
state: current.state
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private dialogId(value: string): string {
|
||||
const decoded = decodeURIComponent(value).trim();
|
||||
if (!SAFE_DIALOG_ID.test(decoded)) {
|
||||
throw new BadRequestException({
|
||||
code: 'ACTIVE_CALL_ID_INVALID',
|
||||
message: 'Active call id is invalid.'
|
||||
});
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenSipsOperationFailed(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object' || !('getResponse' in error) || typeof error.getResponse !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const response = error.getResponse() as unknown;
|
||||
if (!response || typeof response !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const record = response as { code?: unknown; detail?: { code?: unknown; message?: unknown } };
|
||||
return record.code === 'ACTIVE_CALLS_MI_ERROR' && record.detail?.code === 500 && record.detail.message === 'Operation failed';
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function normalizeDialogs(result: unknown, now = new Date()): ActiveCallSummary[] {
|
||||
const records = collectDialogRecords(result);
|
||||
return records
|
||||
.map((record) => toSummary(record, now))
|
||||
.filter((call): call is ActiveCallSummary => call !== null)
|
||||
.sort((left, right) => (right.durationSec ?? 0) - (left.durationSec ?? 0));
|
||||
}
|
||||
|
||||
function collectDialogRecords(value: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => collectDialogRecords(item));
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (looksLikeDialog(value)) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
return Object.values(value).flatMap((item) => collectDialogRecords(item));
|
||||
}
|
||||
|
||||
function looksLikeDialog(record: Record<string, unknown>): boolean {
|
||||
const keys = Object.keys(record).map((key) => key.toLowerCase().replaceAll(/[^a-z0-9]/g, ''));
|
||||
return keys.some((key) => key === 'id' || key === 'dialogid' || key === 'dlgdid' || key === 'did') && keys.some((key) => key === 'callid');
|
||||
}
|
||||
|
||||
function toSummary(record: Record<string, unknown>, now: Date): ActiveCallSummary | null {
|
||||
const callId = stringField(record, ['callid', 'call_id', 'Call-ID', 'call-id']);
|
||||
const id = stringField(record, ['ID', 'id', 'dialog_id', 'dialogId', 'dlg_id', 'dlgId', 'DID', 'did']) ?? callId;
|
||||
if (!id || !callId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdRaw = stringField(record, ['start_time', 'startTime', 'created', 'creation_time', 'init_ts', 'timestamp']);
|
||||
const startedAt = normalizeTime(createdRaw);
|
||||
const lifetimeSec = numberField(record, ['lifetime', 'duration', 'timeout']);
|
||||
const durationSec = startedAt ? Math.max(0, Math.floor((now.getTime() - new Date(startedAt).getTime()) / 1000)) : lifetimeSec;
|
||||
const calleeRecord = firstRecordField(record, ['CALLEES', 'callees', 'callee']);
|
||||
const callerContact = stringField(record, ['caller_contact', 'from_contact', 'fromContact']);
|
||||
const calleeContact =
|
||||
stringField(record, ['callee_contact', 'to_contact', 'toContact']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_contact', 'to_contact', 'toContact']) : null);
|
||||
const callerSdp = stringField(record, ['caller_sdp', 'callerSdp', 'from_sdp', 'sdp']);
|
||||
const calleeSdp =
|
||||
stringField(record, ['callee_sdp', 'calleeSdp', 'to_sdp']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_sdp', 'calleeSdp', 'to_sdp', 'sdp']) : null);
|
||||
|
||||
return {
|
||||
id,
|
||||
callId,
|
||||
fromTag: stringField(record, ['from_tag', 'fromtag', 'fromTag']),
|
||||
toTag: stringField(record, ['to_tag', 'totag', 'toTag']),
|
||||
caller: stringField(record, ['from_uri', 'fromUri', 'caller', 'caller_uri']),
|
||||
callee: stringField(record, ['to_uri', 'toUri', 'callee', 'callee_uri']),
|
||||
state: stringField(record, ['state', 'status']),
|
||||
startedAt,
|
||||
durationSec,
|
||||
lifetimeSec,
|
||||
callerIp: firstNonNull([
|
||||
hostFromSipAddress(callerContact),
|
||||
ipFromSdp(callerSdp),
|
||||
ipFromSipAddress(stringField(record, ['from_uri', 'fromUri', 'caller', 'caller_uri'])),
|
||||
ipFromSipAddress(stringField(record, ['caller_bind_addr', 'from_bind_addr']))
|
||||
]),
|
||||
landingIp: firstNonNull([
|
||||
hostFromSipAddress(calleeContact),
|
||||
ipFromSdp(calleeSdp),
|
||||
ipFromSipAddress(stringField(record, ['to_uri', 'toUri', 'callee', 'callee_uri'])),
|
||||
ipFromSipAddress(stringField(record, ['callee_bind_addr', 'to_bind_addr']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_bind_addr', 'to_bind_addr']) : null))
|
||||
]),
|
||||
callerContact,
|
||||
calleeContact,
|
||||
raw: record
|
||||
};
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
const value = findCaseInsensitive(record, name);
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function numberField(record: Record<string, unknown>, names: string[]): number | null {
|
||||
for (const name of names) {
|
||||
const value = findCaseInsensitive(record, name);
|
||||
const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number.parseInt(value, 10) : NaN;
|
||||
if (Number.isSafeInteger(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCaseInsensitive(record: Record<string, unknown>, name: string): unknown {
|
||||
const wanted = normalizeKey(name);
|
||||
const found = Object.entries(record).find(([key]) => normalizeKey(key) === wanted);
|
||||
return found?.[1];
|
||||
}
|
||||
|
||||
function firstRecordField(record: Record<string, unknown>, names: string[]): Record<string, unknown> | null {
|
||||
for (const name of names) {
|
||||
const value = findCaseInsensitive(record, name);
|
||||
if (isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const found = value.find(isRecord);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstNonNull(values: Array<string | null>): string | null {
|
||||
return values.find((value): value is string => !!value) ?? null;
|
||||
}
|
||||
|
||||
function hostFromSipAddress(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = value.match(/^(?:sips?:)?(?:[^@;\s]+@)?\[?([A-Fa-f0-9:.]+)\]?/);
|
||||
return match ? normalizeIp(match[1]) : null;
|
||||
}
|
||||
|
||||
function ipFromSipAddress(value: string | null): string | null {
|
||||
return normalizeIp(hostFromSipAddress(value));
|
||||
}
|
||||
|
||||
function ipFromSdp(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = value.match(/^c=IN IP[46] ([^\r\n\s]+)/m);
|
||||
return match ? normalizeIp(match[1]) : null;
|
||||
}
|
||||
|
||||
function normalizeIp(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim().replace(/^\[/, '').replace(/\]$/, '');
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(trimmed) || /^[A-Fa-f0-9:]+$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string {
|
||||
return value.toLowerCase().replaceAll(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function normalizeTime(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
const seconds = Number.parseInt(value, 10);
|
||||
if (Number.isSafeInteger(seconds) && seconds > 0) {
|
||||
return new Date(seconds * 1000).toISOString();
|
||||
}
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { BadGatewayException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { RuntimeConfig } from '../../shared/config.js';
|
||||
|
||||
export interface MiResponse<T = unknown> {
|
||||
jsonrpc?: string;
|
||||
result?: T;
|
||||
error?: {
|
||||
code?: number;
|
||||
message?: string;
|
||||
};
|
||||
id?: number | string | null;
|
||||
}
|
||||
|
||||
export interface CommandExecutor {
|
||||
run(args: string[], timeoutMs: number): Promise<string>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SshCommandExecutor implements CommandExecutor {
|
||||
run(args: string[], timeoutMs: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`ssh command timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve(Buffer.concat(stdout).toString('utf8'));
|
||||
return;
|
||||
}
|
||||
reject(new Error(Buffer.concat(stderr).toString('utf8') || `ssh exited with ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpenSipsMiClient {
|
||||
private readonly executor: CommandExecutor = new SshCommandExecutor();
|
||||
|
||||
constructor(private readonly configService: ConfigService<RuntimeConfig, true>) {}
|
||||
|
||||
async listDialogs(): Promise<unknown> {
|
||||
return this.call('dlg_list');
|
||||
}
|
||||
|
||||
async endDialog(dialogId: string): Promise<unknown> {
|
||||
return this.call('dlg_end_dlg', [dialogId]);
|
||||
}
|
||||
|
||||
private async call(method: string, params: string[] = []): Promise<unknown> {
|
||||
const config = this.configService.get('activeCalls', { infer: true });
|
||||
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
|
||||
const args = [];
|
||||
if (config.sshConfig) {
|
||||
args.push('-F', config.sshConfig);
|
||||
}
|
||||
args.push(config.sshHost, remoteCommand);
|
||||
|
||||
let output: string;
|
||||
try {
|
||||
output = await this.executor.run(args, config.timeoutMs);
|
||||
} catch (error) {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
|
||||
message: 'OpenSIPS control plane is unavailable.',
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
|
||||
let response: MiResponse;
|
||||
try {
|
||||
response = JSON.parse(output) as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_ERROR',
|
||||
message: response.error.message ?? 'OpenSIPS MI command failed.',
|
||||
detail: response.error
|
||||
});
|
||||
}
|
||||
|
||||
return response.result;
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import { LOG_REDACT_PATHS } from '@lisglosips/observability';
|
||||
import { appConfig, validationSchema } from '../shared/config.js';
|
||||
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 { 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';
|
||||
import { CustomersModule } from './customers/customers.module.js';
|
||||
@@ -15,6 +17,7 @@ import { DashboardModule } from './dashboard/dashboard.module.js';
|
||||
import { DatabaseModule } from './database/database.module.js';
|
||||
import { HealthModule } from './health/health.module.js';
|
||||
import { LandingLineGroupsModule } from './landing-line-groups/landing-line-groups.module.js';
|
||||
import { NumberLibraryModule } from './number-library/number-library.module.js';
|
||||
import { RechargesModule } from './recharges/recharges.module.js';
|
||||
import { RecordingsModule } from './recordings/recordings.module.js';
|
||||
import { QualityModule } from './quality/quality.module.js';
|
||||
@@ -55,6 +58,8 @@ import { VendorsModule } from './vendors/vendors.module.js';
|
||||
SecurityModule,
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
ActiveCallsModule,
|
||||
CdrsModule,
|
||||
DashboardModule,
|
||||
CustomersModule,
|
||||
CustomerGatewaysModule,
|
||||
@@ -63,6 +68,7 @@ import { VendorsModule } from './vendors/vendors.module.js';
|
||||
VendorsModule,
|
||||
VendorGatewaysModule,
|
||||
LandingLineGroupsModule,
|
||||
NumberLibraryModule,
|
||||
QualityModule,
|
||||
RecordingsModule,
|
||||
UsersModule,
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import { Body, Controller, HttpCode, Inject, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, Inject, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Public } from '../security/security.metadata.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { CaptchaService } from './captcha.service.js';
|
||||
import { parseCookie, serializeCookie } from './cookie.js';
|
||||
|
||||
interface LoginBody {
|
||||
username?: unknown;
|
||||
password?: unknown;
|
||||
captchaId?: unknown;
|
||||
captchaCode?: unknown;
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Public()
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
|
||||
constructor(
|
||||
@Inject(AuthService) private readonly authService: AuthService,
|
||||
@Inject(CaptchaService) private readonly captchaService: CaptchaService
|
||||
) {}
|
||||
|
||||
@Get('captcha')
|
||||
@ApiOperation({ summary: 'Create a one-time image captcha for login' })
|
||||
captcha() {
|
||||
const challenge = this.captchaService.createChallenge();
|
||||
|
||||
return {
|
||||
captchaId: challenge.id,
|
||||
imageDataUrl: challenge.imageDataUrl,
|
||||
expiresAt: challenge.expiresAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Login with username and password' })
|
||||
async login(@Body() body: LoginBody, @Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
const { username, password } = this.readCredentials(body);
|
||||
const { username, password, captchaId, captchaCode } = this.readCredentials(body);
|
||||
if (!this.captchaService.verify(captchaId, captchaCode)) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'AUTH_CAPTCHA_INVALID',
|
||||
message: 'Captcha verification failed.'
|
||||
});
|
||||
}
|
||||
const result = await this.authService.login(username, password, this.requestContext(request));
|
||||
|
||||
this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds);
|
||||
@@ -56,8 +80,17 @@ export class AuthController {
|
||||
);
|
||||
}
|
||||
|
||||
private readCredentials(body: LoginBody): { username: string; password: string } {
|
||||
if (typeof body.username !== 'string' || typeof body.password !== 'string' || !body.username.trim() || !body.password) {
|
||||
private readCredentials(body: LoginBody): { username: string; password: string; captchaId: string; captchaCode: string } {
|
||||
if (
|
||||
typeof body.username !== 'string' ||
|
||||
typeof body.password !== 'string' ||
|
||||
typeof body.captchaId !== 'string' ||
|
||||
typeof body.captchaCode !== 'string' ||
|
||||
!body.username.trim() ||
|
||||
!body.password ||
|
||||
!body.captchaId ||
|
||||
!body.captchaCode.trim()
|
||||
) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'AUTH_INVALID_CREDENTIALS',
|
||||
message: 'Invalid username or password.'
|
||||
@@ -66,7 +99,9 @@ export class AuthController {
|
||||
|
||||
return {
|
||||
username: body.username,
|
||||
password: body.password
|
||||
password: body.password,
|
||||
captchaId: body.captchaId,
|
||||
captchaCode: body.captchaCode
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { hashPasswordArgon2id, sha256Token } from '@lisglosips/auth';
|
||||
import { CaptchaService } from './captcha.service.js';
|
||||
import { AUTH_REPOSITORY, type AuthRepository, type AuthSessionRecord, type AuthUserRecord, type CreateSessionInput } from './auth.types.js';
|
||||
|
||||
class E2eAuthRepository implements AuthRepository {
|
||||
@@ -77,6 +78,7 @@ class E2eAuthRepository implements AuthRepository {
|
||||
describe('LisgloSIPS Auth API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let repo: E2eAuthRepository;
|
||||
let captchaService: CaptchaService;
|
||||
let secret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -101,6 +103,7 @@ describe('LisgloSIPS Auth API', () => {
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
captchaService = app.get(CaptchaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -108,9 +111,20 @@ describe('LisgloSIPS Auth API', () => {
|
||||
});
|
||||
|
||||
it('logs in, refreshes with cookie rotation, and logs out', async () => {
|
||||
const login = await request(app.getHttpServer())
|
||||
const publicCaptcha = await request(app.getHttpServer()).get('/api/v2/auth/captcha').expect(200);
|
||||
expect(publicCaptcha.body.captchaId).toBeTypeOf('string');
|
||||
expect(publicCaptcha.body.imageDataUrl).toMatch(/^data:image\/svg\+xml;base64,/);
|
||||
expect(publicCaptcha.body.answer).toBeUndefined();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/auth/login')
|
||||
.send({ username: 'operator', password: secret })
|
||||
.expect(401);
|
||||
|
||||
const captcha = captchaService.createChallenge();
|
||||
const login = await request(app.getHttpServer())
|
||||
.post('/api/v2/auth/login')
|
||||
.send({ username: 'operator', password: secret, captchaId: captcha.id, captchaCode: captcha.answer })
|
||||
.expect(200);
|
||||
const loginCookie = login.headers['set-cookie'][0];
|
||||
const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? '';
|
||||
|
||||
@@ -2,18 +2,20 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { PrismaAuthRepository } from './auth.repository.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { CaptchaService } from './captcha.service.js';
|
||||
import { AUTH_REPOSITORY } from './auth.types.js';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
CaptchaService,
|
||||
PrismaAuthRepository,
|
||||
{
|
||||
provide: AUTH_REPOSITORY,
|
||||
useExisting: PrismaAuthRepository
|
||||
}
|
||||
],
|
||||
exports: [AuthService]
|
||||
exports: [AuthService, CaptchaService]
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -77,16 +77,19 @@ class MemoryAuthRepository implements AuthRepository {
|
||||
}
|
||||
}
|
||||
|
||||
function config() {
|
||||
function config(overrides: Record<string, unknown> = {}) {
|
||||
const values = new Map<string, unknown>([
|
||||
['auth.accessTokenSecret', 'test-only-access-token-secret-min-32-bytes'],
|
||||
['auth.accessTokenTtlSeconds', 900],
|
||||
['auth.refreshTokenTtlDays', 7],
|
||||
['auth.lockMaxFailures', 3],
|
||||
['auth.lockWindowSeconds', 60],
|
||||
['auth.loginThrottleMaxFailures', 10],
|
||||
['auth.loginThrottleWindowSeconds', 300],
|
||||
['auth.tokenIssuer', 'lisglosips-api'],
|
||||
['auth.tokenAudience', 'lisglosips-web']
|
||||
]);
|
||||
Object.entries(overrides).forEach(([key, value]) => values.set(key, value));
|
||||
|
||||
return {
|
||||
get: (key: string) => values.get(key)
|
||||
@@ -124,6 +127,15 @@ describe('AuthService', () => {
|
||||
await expect(service.login('operator', secret)).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('throttles repeated login attempts even when the username is unknown', async () => {
|
||||
service = new AuthService(repo, config({ 'auth.loginThrottleMaxFailures': 3 }) as never);
|
||||
|
||||
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 });
|
||||
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 });
|
||||
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 });
|
||||
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it('rotates refresh sessions and rejects reuse', async () => {
|
||||
const login = await service.login('operator', secret);
|
||||
const refresh = await service.refresh(login.tokens.refreshToken);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { HttpException, HttpStatus, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
ACCESS_TOKEN_TYPE,
|
||||
@@ -36,8 +36,15 @@ export interface AuthResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginThrottleRecord {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly loginThrottle = new Map<string, LoginThrottleRecord>();
|
||||
|
||||
constructor(
|
||||
@Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository,
|
||||
@Inject(ConfigService) private readonly config: ConfigService<RuntimeConfig, true>
|
||||
@@ -45,23 +52,30 @@ export class AuthService {
|
||||
|
||||
async login(username: string, password: string, context: AuthRequestContext = {}): Promise<{ response: AuthResponse; tokens: AuthTokens }> {
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
const user = await this.repository.findUserByUsername(normalizedUsername);
|
||||
const now = new Date();
|
||||
|
||||
this.assertLoginAllowed(normalizedUsername, context.ip, now);
|
||||
|
||||
const user = await this.repository.findUserByUsername(normalizedUsername);
|
||||
|
||||
if (!user) {
|
||||
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
if (this.isLocked(user, now) || user.status !== 'ENABLED' || !user.passwordHash || user.passwordAlgo !== PASSWORD_ALGO_ARGON2ID) {
|
||||
await this.registerFailure(user, now);
|
||||
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
if (!(await verifyPasswordArgon2id(password, user.passwordHash))) {
|
||||
await this.registerFailure(user, now);
|
||||
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
this.clearLoginThrottleForUser(normalizedUsername);
|
||||
await this.repository.markLoginSuccess(user.id, context.ip);
|
||||
const tokens = await this.issueTokens(user, context);
|
||||
|
||||
@@ -175,6 +189,53 @@ export class AuthService {
|
||||
return Boolean(user.lockedUntil && user.lockedUntil > now);
|
||||
}
|
||||
|
||||
private assertLoginAllowed(username: string, ip: string | undefined, now: Date): void {
|
||||
const maxFailures = this.config.get('auth.loginThrottleMaxFailures', { infer: true });
|
||||
const nowMs = now.getTime();
|
||||
|
||||
for (const key of this.throttleKeys(username, ip)) {
|
||||
const record = this.loginThrottle.get(key);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
if (record.resetAt <= nowMs) {
|
||||
this.loginThrottle.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (record.count >= maxFailures) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: 'AUTH_LOGIN_THROTTLED',
|
||||
message: 'Too many login attempts. Please try again later.'
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerLoginThrottleFailure(username: string, ip: string | undefined, now: Date): void {
|
||||
const windowSeconds = this.config.get('auth.loginThrottleWindowSeconds', { infer: true });
|
||||
const resetAt = now.getTime() + windowSeconds * 1000;
|
||||
|
||||
for (const key of this.throttleKeys(username, ip)) {
|
||||
const record = this.loginThrottle.get(key);
|
||||
if (!record || record.resetAt <= now.getTime()) {
|
||||
this.loginThrottle.set(key, { count: 1, resetAt });
|
||||
} else {
|
||||
this.loginThrottle.set(key, { count: record.count + 1, resetAt: record.resetAt });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearLoginThrottleForUser(username: string): void {
|
||||
this.loginThrottle.delete(`user:${username}`);
|
||||
}
|
||||
|
||||
private throttleKeys(username: string, ip: string | undefined): string[] {
|
||||
return [`user:${username}`, `ip:${ip || 'unknown'}`];
|
||||
}
|
||||
|
||||
private invalidCredentials(): UnauthorizedException {
|
||||
return new UnauthorizedException({
|
||||
code: 'AUTH_INVALID_CREDENTIALS',
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export interface CaptchaChallenge {
|
||||
id: string;
|
||||
answer: string;
|
||||
imageDataUrl: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
interface StoredCaptcha {
|
||||
answer: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const CAPTCHA_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const CAPTCHA_LENGTH = 5;
|
||||
const CAPTCHA_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
private readonly challenges = new Map<string, StoredCaptcha>();
|
||||
|
||||
createChallenge(now = new Date()): CaptchaChallenge {
|
||||
this.cleanup(now.getTime());
|
||||
const id = crypto.randomUUID();
|
||||
const answer = this.randomAnswer();
|
||||
const expiresAt = new Date(now.getTime() + CAPTCHA_TTL_MS);
|
||||
|
||||
this.challenges.set(id, {
|
||||
answer,
|
||||
expiresAt: expiresAt.getTime()
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
answer,
|
||||
expiresAt,
|
||||
imageDataUrl: this.renderSvgDataUrl(answer, id)
|
||||
};
|
||||
}
|
||||
|
||||
verify(id: string, answer: string, now = new Date()): boolean {
|
||||
this.cleanup(now.getTime());
|
||||
const challenge = this.challenges.get(id);
|
||||
this.challenges.delete(id);
|
||||
|
||||
if (!challenge || challenge.expiresAt <= now.getTime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = answer.trim().toUpperCase();
|
||||
return normalized.length === challenge.answer.length && crypto.timingSafeEqual(Buffer.from(normalized), Buffer.from(challenge.answer));
|
||||
}
|
||||
|
||||
private randomAnswer(): string {
|
||||
let answer = '';
|
||||
for (let index = 0; index < CAPTCHA_LENGTH; index += 1) {
|
||||
answer += CAPTCHA_ALPHABET[crypto.randomInt(0, CAPTCHA_ALPHABET.length)];
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
private renderSvgDataUrl(answer: string, id: string): string {
|
||||
const shortId = id.replaceAll('-', '').slice(0, 8);
|
||||
const noise = Array.from({ length: 8 }, (_, index) => {
|
||||
const x1 = crypto.randomInt(0, 132);
|
||||
const y1 = crypto.randomInt(8, 42);
|
||||
const x2 = crypto.randomInt(0, 132);
|
||||
const y2 = crypto.randomInt(8, 42);
|
||||
const opacity = index % 2 === 0 ? '0.24' : '0.16';
|
||||
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#2563eb" stroke-width="1.3" opacity="${opacity}" />`;
|
||||
}).join('');
|
||||
const chars = answer.split('').map((char, index) => {
|
||||
const x = 18 + index * 21;
|
||||
const y = 32 + crypto.randomInt(-3, 4);
|
||||
const rotate = crypto.randomInt(-12, 13);
|
||||
return `<text x="${x}" y="${y}" transform="rotate(${rotate} ${x} ${y})">${char}</text>`;
|
||||
}).join('');
|
||||
const dots = Array.from({ length: 20 }, () => {
|
||||
const cx = crypto.randomInt(4, 128);
|
||||
const cy = crypto.randomInt(5, 43);
|
||||
return `<circle cx="${cx}" cy="${cy}" r="1" fill="#94a3b8" opacity="0.55" />`;
|
||||
}).join('');
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="132" height="48" viewBox="0 0 132 48" role="img" aria-label="captcha ${shortId}">
|
||||
<rect width="132" height="48" rx="7" fill="#f8fafc"/>
|
||||
${noise}
|
||||
${dots}
|
||||
<g font-family="Inter, Arial, sans-serif" font-size="24" font-weight="800" letter-spacing="2" fill="#111827">${chars}</g>
|
||||
</svg>`;
|
||||
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
||||
}
|
||||
|
||||
private cleanup(nowMs: number): void {
|
||||
for (const [id, challenge] of this.challenges.entries()) {
|
||||
if (challenge.expiresAt <= nowMs) {
|
||||
this.challenges.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Inject, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermissions } from '../security/security.metadata.js';
|
||||
import { CdrsService } from './cdrs.service.js';
|
||||
|
||||
@ApiTags('cdrs')
|
||||
@Controller('cdrs')
|
||||
export class CdrsController {
|
||||
constructor(@Inject(CdrsService) private readonly cdrsService: CdrsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('cdr.view')
|
||||
list(@Query() query: Record<string, unknown>) {
|
||||
return this.cdrsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('cdr.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.cdrsService.get(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CdrsController } from './cdrs.controller.js';
|
||||
import { CdrsService } from './cdrs.service.js';
|
||||
import { CDRS_REPOSITORY, PrismaCdrsRepository } from './cdrs.repository.js';
|
||||
|
||||
@Module({
|
||||
controllers: [CdrsController],
|
||||
providers: [
|
||||
CdrsService,
|
||||
PrismaCdrsRepository,
|
||||
{
|
||||
provide: CDRS_REPOSITORY,
|
||||
useExisting: PrismaCdrsRepository
|
||||
}
|
||||
]
|
||||
})
|
||||
export class CdrsModule {}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type CdrCarrier = 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN';
|
||||
|
||||
export interface CdrQuery {
|
||||
caller?: string;
|
||||
callee?: string;
|
||||
customerGatewayId?: string;
|
||||
vendorGatewayId?: string;
|
||||
cityCode?: string;
|
||||
carrier?: CdrCarrier;
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
export interface CdrListItem {
|
||||
id: string;
|
||||
eventId: string;
|
||||
callId: string;
|
||||
sourceIp: string | null;
|
||||
caller: string;
|
||||
callee: string;
|
||||
calleeCityCode: string | null;
|
||||
calleeCityName: string | null;
|
||||
calleeProvinceName: string | null;
|
||||
calleeOperator: CdrCarrier;
|
||||
calleeNumberType: string;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
customerGatewayId: string | null;
|
||||
customerGatewayName: string | null;
|
||||
vendorId: string | null;
|
||||
vendorName: string | null;
|
||||
vendorGatewayId: string | null;
|
||||
vendorGatewayName: string | null;
|
||||
vendorGatewayHost: string | null;
|
||||
vendorGatewayPort: number | null;
|
||||
lineGroupId: string | null;
|
||||
lineGroupName: string | null;
|
||||
startedAt: Date;
|
||||
answeredAt: Date | null;
|
||||
endedAt: Date;
|
||||
durationSec: number;
|
||||
sipCode: number;
|
||||
hangupReason: string | null;
|
||||
recordingKey: string | null;
|
||||
configVersion: number | null;
|
||||
ratingStatus: string;
|
||||
customerFee: string | null;
|
||||
vendorCost: string | null;
|
||||
grossProfit: string | null;
|
||||
billSec: number | null;
|
||||
}
|
||||
|
||||
export interface CdrsRepository {
|
||||
list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }>;
|
||||
get(id: string): Promise<CdrListItem>;
|
||||
}
|
||||
|
||||
export const CDRS_REPOSITORY = Symbol('CDRS_REPOSITORY');
|
||||
|
||||
type RawCdrRecord = Prisma.RawCdrGetPayload<{
|
||||
include: {
|
||||
customer: { select: { name: true } };
|
||||
customerGateway: { select: { name: true } };
|
||||
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 } };
|
||||
};
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class PrismaCdrsRepository implements CdrsRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }> {
|
||||
const where = this.where(query);
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.rawCdr.findMany({
|
||||
where,
|
||||
orderBy: [{ startedAt: 'desc' }],
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
include: this.includeCdr()
|
||||
}),
|
||||
this.prisma.rawCdr.count({ where })
|
||||
]);
|
||||
return { items: items.map((item) => this.toItem(item)), total };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CdrListItem> {
|
||||
const item = await this.prisma.rawCdr.findUnique({
|
||||
where: { id },
|
||||
include: this.includeCdr()
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException({ code: 'CDR_NOT_FOUND', message: 'CDR not found.' });
|
||||
}
|
||||
return this.toItem(item);
|
||||
}
|
||||
|
||||
private where(query: CdrQuery): Prisma.RawCdrWhereInput {
|
||||
return {
|
||||
caller: query.caller ? { contains: query.caller } : undefined,
|
||||
callee: query.callee ? { contains: query.callee } : undefined,
|
||||
customerGatewayId: query.customerGatewayId,
|
||||
vendorGatewayId: query.vendorGatewayId,
|
||||
calleeCityCode: query.cityCode,
|
||||
calleeOperator: query.carrier
|
||||
};
|
||||
}
|
||||
|
||||
private includeCdr() {
|
||||
return {
|
||||
customer: { select: { name: true } },
|
||||
customerGateway: { select: { name: true } },
|
||||
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 } }
|
||||
} satisfies Prisma.RawCdrInclude;
|
||||
}
|
||||
|
||||
private toItem(item: RawCdrRecord): CdrListItem {
|
||||
return {
|
||||
id: item.id,
|
||||
eventId: item.eventId,
|
||||
callId: item.callId,
|
||||
sourceIp: item.sourceIp,
|
||||
caller: item.caller,
|
||||
callee: item.callee,
|
||||
calleeCityCode: item.calleeCityCode,
|
||||
calleeCityName: item.calleeCityName,
|
||||
calleeProvinceName: item.calleeProvinceName,
|
||||
calleeOperator: item.calleeOperator,
|
||||
calleeNumberType: item.calleeNumberType,
|
||||
customerId: item.customerId,
|
||||
customerName: item.customer?.name ?? null,
|
||||
customerGatewayId: item.customerGatewayId,
|
||||
customerGatewayName: item.customerGateway?.name ?? null,
|
||||
vendorId: item.vendorId,
|
||||
vendorName: item.vendor?.name ?? null,
|
||||
vendorGatewayId: item.vendorGatewayId,
|
||||
vendorGatewayName: item.vendorGateway?.name ?? null,
|
||||
vendorGatewayHost: item.vendorGateway?.host ?? null,
|
||||
vendorGatewayPort: item.vendorGateway?.port ?? null,
|
||||
lineGroupId: item.lineGroupId,
|
||||
lineGroupName: item.lineGroup?.name ?? null,
|
||||
startedAt: item.startedAt,
|
||||
answeredAt: item.answeredAt,
|
||||
endedAt: item.endedAt,
|
||||
durationSec: item.durationSec,
|
||||
sipCode: item.sipCode,
|
||||
hangupReason: item.hangupReason,
|
||||
recordingKey: item.recordingKey,
|
||||
configVersion: item.configVersion,
|
||||
ratingStatus: item.ratingStatus,
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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';
|
||||
|
||||
class MemoryCdrsRepository implements CdrsRepository {
|
||||
lastQuery: CdrQuery | null = null;
|
||||
|
||||
async list(query: CdrQuery) {
|
||||
this.lastQuery = query;
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CdrListItem> {
|
||||
return {
|
||||
id,
|
||||
eventId: 'evt_1',
|
||||
callId: 'call_1',
|
||||
sourceIp: null,
|
||||
caller: '1001',
|
||||
callee: '13800138000',
|
||||
calleeCityCode: '340100',
|
||||
calleeCityName: '合肥市',
|
||||
calleeProvinceName: '安徽省',
|
||||
calleeOperator: 'MOBILE',
|
||||
calleeNumberType: 'MOBILE',
|
||||
customerId: null,
|
||||
customerName: null,
|
||||
customerGatewayId: null,
|
||||
customerGatewayName: null,
|
||||
vendorId: null,
|
||||
vendorName: null,
|
||||
vendorGatewayId: null,
|
||||
vendorGatewayName: null,
|
||||
vendorGatewayHost: null,
|
||||
vendorGatewayPort: null,
|
||||
lineGroupId: null,
|
||||
lineGroupName: null,
|
||||
startedAt: new Date(),
|
||||
answeredAt: null,
|
||||
endedAt: new Date(),
|
||||
durationSec: 0,
|
||||
sipCode: 503,
|
||||
hangupReason: 'NO_VENDOR_ROUTE_REGION_BLOCKED',
|
||||
recordingKey: null,
|
||||
configVersion: null,
|
||||
ratingStatus: 'SKIPPED',
|
||||
customerFee: null,
|
||||
vendorCost: null,
|
||||
grossProfit: null,
|
||||
billSec: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('CDR service', () => {
|
||||
it('normalizes filters for city and carrier queries', async () => {
|
||||
const repository = new MemoryCdrsRepository();
|
||||
const service = new CdrsService(repository);
|
||||
|
||||
await service.list({ caller: '1001', cityCode: '340100', carrier: 'MOBILE', take: '20', skip: '5' });
|
||||
|
||||
expect(repository.lastQuery).toMatchObject({
|
||||
caller: '1001',
|
||||
cityCode: '340100',
|
||||
carrier: 'MOBILE',
|
||||
take: 20,
|
||||
skip: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid carrier filters', async () => {
|
||||
const service = new CdrsService(new MemoryCdrsRepository());
|
||||
|
||||
expect(() => service.list({ carrier: 'BAD' })).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { CDRS_REPOSITORY, type CdrCarrier, type CdrQuery, type CdrsRepository } from './cdrs.repository.js';
|
||||
|
||||
@Injectable()
|
||||
export class CdrsService {
|
||||
constructor(@Inject(CDRS_REPOSITORY) private readonly cdrs: CdrsRepository) {}
|
||||
|
||||
list(rawQuery: Record<string, unknown>) {
|
||||
const query: CdrQuery = {
|
||||
caller: this.optionalString(rawQuery.caller, 64),
|
||||
callee: this.optionalString(rawQuery.callee, 64),
|
||||
customerGatewayId: this.optionalString(rawQuery.customerGatewayId, 32),
|
||||
vendorGatewayId: this.optionalString(rawQuery.vendorGatewayId, 32),
|
||||
cityCode: this.optionalString(rawQuery.cityCode, 12),
|
||||
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
|
||||
take: this.int(rawQuery.take, 100, 1, 500),
|
||||
skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
|
||||
};
|
||||
return this.cdrs.list(query);
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.cdrs.get(id);
|
||||
}
|
||||
|
||||
private optionalString(value: unknown, maxLength: number): string | 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 trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > maxLength) {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private carrier(value: unknown): CdrCarrier {
|
||||
if (value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' || value === 'UNKNOWN') {
|
||||
return value;
|
||||
}
|
||||
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
|
||||
}
|
||||
|
||||
private int(value: unknown, defaultValue: number, min: number, max: number): number {
|
||||
if (value === undefined) return defaultValue;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
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';
|
||||
@@ -48,4 +48,11 @@ export class CustomerGatewaysController {
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.disable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'delete', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.remove(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,13 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async softDelete(gatewayId: string): Promise<CustomerGatewaySummary> {
|
||||
const current = await this.get(gatewayId);
|
||||
const deleted = { ...current, status: 'DISABLED' as CustomerGatewayStatus, policyCount: 0 };
|
||||
this.gateways.delete(gatewayId);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private summary(input: {
|
||||
id: string;
|
||||
customerId?: string;
|
||||
@@ -269,5 +276,7 @@ describe('S13 customer gateways API', () => {
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).delete('/api/v2/customer-gateways/cgw_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
||||
expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'delete' && entry.result === 'SUCCESS')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface CustomerGatewaysRepository {
|
||||
create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||
update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||
setStatus(gatewayId: string, status: CustomerGatewayStatus, actorId?: string): Promise<CustomerGatewaySummary>;
|
||||
softDelete(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary>;
|
||||
}
|
||||
|
||||
export const CUSTOMER_GATEWAYS_REPOSITORY = Symbol('CUSTOMER_GATEWAYS_REPOSITORY');
|
||||
@@ -168,6 +169,35 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
async softDelete(gatewayIdValue: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
await this.findActiveOrThrow(gatewayIdValue);
|
||||
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.customerGatewayPolicy.updateMany({
|
||||
where: { gatewayId: gatewayIdValue, deletedAt: null },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId
|
||||
}
|
||||
});
|
||||
const deleted = await tx.customerGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, deleted.id, 'customer_gateway.deleted');
|
||||
return deleted;
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
|
||||
@@ -103,6 +103,10 @@ export class CustomerGatewaysService {
|
||||
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
remove(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
return this.gateways.softDelete(gatewayId, actorId);
|
||||
}
|
||||
|
||||
private limitedString(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.` });
|
||||
|
||||
@@ -176,6 +176,7 @@ class MemoryLandingLineGroupsRepository implements LandingLineGroupsRepository {
|
||||
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
|
||||
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
||||
policyCount: input.policyCount ?? 0,
|
||||
customerGatewayCount: input.customerGatewayCount ?? 0,
|
||||
items,
|
||||
createdAt: new Date('2026-06-21T07:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T07:00:00.000Z')
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface LineGroupSummary {
|
||||
concurrencyCapSum: number;
|
||||
gatewayConcurrencyLimitSum: number;
|
||||
policyCount: number;
|
||||
customerGatewayCount: number;
|
||||
items: LineGroupItemSummary[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -139,11 +140,14 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: this.includeSummary()
|
||||
});
|
||||
return groups.map((group) => this.toSummary(group));
|
||||
const usageCounts = await this.customerGatewayUsageCounts(groups.map((group) => group.id));
|
||||
return groups.map((group) => this.toSummary(group, usageCounts.get(group.id) ?? 0));
|
||||
}
|
||||
|
||||
async get(lineGroupIdValue: string): Promise<LineGroupSummary> {
|
||||
return this.toSummary(await this.findActiveOrThrow(lineGroupIdValue));
|
||||
const group = await this.findActiveOrThrow(lineGroupIdValue);
|
||||
const usageCounts = await this.customerGatewayUsageCounts([lineGroupIdValue]);
|
||||
return this.toSummary(group, usageCounts.get(lineGroupIdValue) ?? 0);
|
||||
}
|
||||
|
||||
async create(input: CreateLineGroupInput): Promise<LineGroupSummary> {
|
||||
@@ -201,11 +205,10 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
|
||||
async softDelete(lineGroupIdValue: string, actorId?: string): Promise<LineGroupSummary> {
|
||||
await this.findActiveOrThrow(lineGroupIdValue);
|
||||
const linkedPolicies = await this.prisma.customerGatewayPolicy.count({
|
||||
where: { lineGroupId: lineGroupIdValue, deletedAt: null }
|
||||
});
|
||||
if (linkedPolicies > 0) {
|
||||
throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active policies cannot be deleted.' });
|
||||
const usageCounts = await this.customerGatewayUsageCounts([lineGroupIdValue]);
|
||||
const linkedCustomerGateways = usageCounts.get(lineGroupIdValue) ?? 0;
|
||||
if (linkedCustomerGateways > 0) {
|
||||
throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active customer gateways cannot be deleted.' });
|
||||
}
|
||||
|
||||
const group = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -222,7 +225,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
await this.enqueueConfigOutbox(tx, deleted.id, 'line_group.deleted');
|
||||
return deleted;
|
||||
});
|
||||
return this.toSummary(group);
|
||||
return this.toSummary(group, 0);
|
||||
}
|
||||
|
||||
async addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary> {
|
||||
@@ -342,6 +345,27 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
return this.get(lineGroupIdValue);
|
||||
}
|
||||
|
||||
private async customerGatewayUsageCounts(lineGroupIds: string[]): Promise<Map<string, number>> {
|
||||
if (lineGroupIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rows = await this.prisma.customerGatewayPolicy.groupBy({
|
||||
by: ['lineGroupId', 'gatewayId'],
|
||||
where: {
|
||||
lineGroupId: { in: lineGroupIds },
|
||||
deletedAt: null
|
||||
}
|
||||
});
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (!row.lineGroupId) {
|
||||
continue;
|
||||
}
|
||||
counts.set(row.lineGroupId, (counts.get(row.lineGroupId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private includeItem() {
|
||||
return {
|
||||
vendorGateway: {
|
||||
@@ -427,7 +451,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
});
|
||||
}
|
||||
|
||||
private toSummary(group: LineGroupRecord): LineGroupSummary {
|
||||
private toSummary(group: LineGroupRecord, customerGatewayCount = 0): LineGroupSummary {
|
||||
const items = group.items.map((item) => this.toItemSummary(item));
|
||||
const enabledItems = items.filter((item) => item.status === 'ENABLED');
|
||||
return {
|
||||
@@ -440,6 +464,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
|
||||
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
||||
policyCount: group._count.policies,
|
||||
customerGatewayCount,
|
||||
items,
|
||||
createdAt: group.createdAt,
|
||||
updatedAt: group.updatedAt
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Body, Controller, Get, Inject, 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 { NumberLibraryService } from './number-library.service.js';
|
||||
|
||||
@ApiTags('number-library')
|
||||
@Controller('number-library')
|
||||
export class NumberLibraryController {
|
||||
constructor(@Inject(NumberLibraryService) private readonly numberLibraryService: NumberLibraryService) {}
|
||||
|
||||
@Get('cities')
|
||||
@RequirePermissions('number_library.view')
|
||||
listCities(@Query() query: unknown) {
|
||||
return this.numberLibraryService.listCities(query as never);
|
||||
}
|
||||
|
||||
@Post('cities/import')
|
||||
@RequirePermissions('number_library.manage')
|
||||
@AuditAction({ module: 'number_library', action: 'import_cities', objectType: 'geo_city' })
|
||||
importCities(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.numberLibraryService.importCities(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Get('phone-segments')
|
||||
@RequirePermissions('number_library.view')
|
||||
listPhoneSegments(@Query() query: unknown) {
|
||||
return this.numberLibraryService.listPhoneSegments(query as never);
|
||||
}
|
||||
|
||||
@Post('phone-segments/import')
|
||||
@RequirePermissions('number_library.manage')
|
||||
@AuditAction({ module: 'number_library', action: 'import_phone_segments', objectType: 'phone_number_segment' })
|
||||
importPhoneSegments(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.numberLibraryService.importPhoneSegments(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Get('area-codes')
|
||||
@RequirePermissions('number_library.view')
|
||||
listAreaCodes(@Query() query: unknown) {
|
||||
return this.numberLibraryService.listAreaCodes(query as never);
|
||||
}
|
||||
|
||||
@Post('area-codes/import')
|
||||
@RequirePermissions('number_library.manage')
|
||||
@AuditAction({ module: 'number_library', action: 'import_area_codes', objectType: 'phone_area_code' })
|
||||
importAreaCodes(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.numberLibraryService.importAreaCodes(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Get('carrier-prefix-rules')
|
||||
@RequirePermissions('number_library.view')
|
||||
listCarrierPrefixRules(@Query() query: unknown) {
|
||||
return this.numberLibraryService.listCarrierPrefixRules(query as never);
|
||||
}
|
||||
|
||||
@Post('carrier-prefix-rules/import')
|
||||
@RequirePermissions('number_library.manage')
|
||||
@AuditAction({ module: 'number_library', action: 'import_carrier_prefix_rules', objectType: 'carrier_prefix_rule' })
|
||||
importCarrierPrefixRules(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.numberLibraryService.importCarrierPrefixRules(body as never, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
NUMBER_LIBRARY_REPOSITORY,
|
||||
type AreaCodeInput,
|
||||
type CarrierPrefixInput,
|
||||
type CityInput,
|
||||
type ImportResult,
|
||||
type NumberCarrier,
|
||||
type NumberLibraryRepository,
|
||||
type PageQuery,
|
||||
type PhoneSegmentInput
|
||||
} from './number-library.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryNumberLibraryRepository implements NumberLibraryRepository {
|
||||
cities: CityInput[] = [];
|
||||
segments: PhoneSegmentInput[] = [];
|
||||
areaCodes: AreaCodeInput[] = [];
|
||||
carrierRules: CarrierPrefixInput[] = [];
|
||||
|
||||
async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> {
|
||||
return this.page(this.cities.filter((city) => !query.keyword || city.cityName.includes(query.keyword) || city.provinceName.includes(query.keyword)), query);
|
||||
}
|
||||
|
||||
async importCities(items: CityInput[]): Promise<ImportResult> {
|
||||
this.upsert(this.cities, items, (item) => item.code);
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
|
||||
return this.page(
|
||||
this.segments.filter(
|
||||
(item) =>
|
||||
(!query.segment7 || item.segment7.startsWith(query.segment7)) &&
|
||||
(!query.cityCode || item.cityCode === query.cityCode) &&
|
||||
(!query.carrier || item.carrier === query.carrier)
|
||||
),
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
async importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult> {
|
||||
this.upsert(this.segments, items, (item) => item.segment7);
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> {
|
||||
return this.page(
|
||||
this.areaCodes.filter((item) => (!query.areaCode || item.areaCode.startsWith(query.areaCode)) && (!query.cityCode || item.cityCode === query.cityCode)),
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
async importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult> {
|
||||
this.upsert(this.areaCodes, items, (item) => item.areaCode);
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
|
||||
return this.page(
|
||||
this.carrierRules.filter((item) => (!query.prefix || item.prefix.startsWith(query.prefix)) && (!query.carrier || item.carrier === query.carrier)),
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult> {
|
||||
this.upsert(this.carrierRules, items, (item) => item.prefix);
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
private upsert<T>(target: T[], items: T[], key: (item: T) => string): void {
|
||||
for (const item of items) {
|
||||
const index = target.findIndex((existing) => key(existing) === key(item));
|
||||
if (index >= 0) {
|
||||
target[index] = item;
|
||||
} else {
|
||||
target.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private page(items: unknown[], query: PageQuery): { items: unknown[]; total: number } {
|
||||
return { items: items.slice(query.skip, query.skip + query.take), total: items.length };
|
||||
}
|
||||
}
|
||||
|
||||
describe('number library API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['number_library.view', 'number_library.manage'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['number_library.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(NUMBER_LIBRARY_REPOSITORY)
|
||||
.useValue(new MemoryNumberLibraryRepository())
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('rejects imports without number_library.manage', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/cities/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.send({ items: [] })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('imports and lists cities, phone segments, area codes, and carrier rules', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/cities/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ code: '340100', provinceCode: '340000', provinceName: '安徽', cityCode: '340100', cityName: '合肥', cityLevel: '地级市' }] })
|
||||
.expect(201)
|
||||
.expect((response) => expect(response.body).toEqual({ imported: 1 }));
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/phone-segments/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ segment7: '1385510', cityCode: '340100', provinceName: '安徽', cityName: '合肥', carrier: 'MOBILE', batchId: 'batch_test' }] })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/area-codes/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ areaCode: '0551', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/carrier-prefix-rules/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ prefix: '138', carrier: 'MOBILE', priority: 10 }] })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v2/number-library/phone-segments?segment7=138&carrier=MOBILE')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body.total).toBe(1);
|
||||
expect(response.body.items[0]).toMatchObject({ segment7: '1385510', cityName: '合肥', carrier: 'MOBILE' });
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v2/number-library/area-codes?areaCode=0551')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200)
|
||||
.expect((response) => expect(response.body.items[0]).toMatchObject({ areaCode: '0551', cityName: '合肥' }));
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v2/number-library/carrier-prefix-rules?prefix=138')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200)
|
||||
.expect((response) => expect(response.body.items[0]).toMatchObject({ prefix: '138', carrier: 'MOBILE' }));
|
||||
|
||||
expect(audit.entries.some((entry) => entry.module === 'number_library' && entry.action === 'import_cities')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates import batch shape and segment digits', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/phone-segments/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ segment7: '13855', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] })
|
||||
.expect(400)
|
||||
.expect((response) => expect(response.body).toMatchObject({ code: 'DIGITS_INVALID' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NumberLibraryController } from './number-library.controller.js';
|
||||
import { NUMBER_LIBRARY_REPOSITORY, PrismaNumberLibraryRepository } from './number-library.repository.js';
|
||||
import { NumberLibraryService } from './number-library.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [NumberLibraryController],
|
||||
providers: [
|
||||
NumberLibraryService,
|
||||
{
|
||||
provide: NUMBER_LIBRARY_REPOSITORY,
|
||||
useClass: PrismaNumberLibraryRepository
|
||||
}
|
||||
],
|
||||
exports: [NumberLibraryService]
|
||||
})
|
||||
export class NumberLibraryModule {}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type NumberCarrier = 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN';
|
||||
|
||||
export interface PageQuery {
|
||||
keyword?: string;
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
export interface CityInput {
|
||||
code: string;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
cityLevel: string;
|
||||
status?: 'ENABLED' | 'DISABLED';
|
||||
effectiveFrom?: Date | null;
|
||||
effectiveTo?: Date | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface PhoneSegmentInput {
|
||||
segment7: string;
|
||||
cityCode: string;
|
||||
provinceName: string;
|
||||
cityName: string;
|
||||
carrier?: NumberCarrier;
|
||||
source?: string | null;
|
||||
batchId?: string | null;
|
||||
effectiveFrom?: Date | null;
|
||||
effectiveTo?: Date | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface AreaCodeInput {
|
||||
areaCode: string;
|
||||
cityCode: string;
|
||||
provinceName: string;
|
||||
cityName: string;
|
||||
source?: string | null;
|
||||
batchId?: string | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface CarrierPrefixInput {
|
||||
prefix: string;
|
||||
carrier: NumberCarrier;
|
||||
priority: number;
|
||||
source?: string | null;
|
||||
batchId?: string | null;
|
||||
effectiveFrom?: Date | null;
|
||||
effectiveTo?: Date | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
}
|
||||
|
||||
export const NUMBER_LIBRARY_REPOSITORY = Symbol('NUMBER_LIBRARY_REPOSITORY');
|
||||
|
||||
function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
export interface NumberLibraryRepository {
|
||||
listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }>;
|
||||
importCities(items: CityInput[]): Promise<ImportResult>;
|
||||
listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>;
|
||||
importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult>;
|
||||
listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }>;
|
||||
importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult>;
|
||||
listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>;
|
||||
importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaNumberLibraryRepository implements NumberLibraryRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> {
|
||||
const where: Prisma.GeoCityWhereInput = {
|
||||
deletedAt: null,
|
||||
OR: query.keyword
|
||||
? [
|
||||
{ provinceName: { contains: query.keyword } },
|
||||
{ cityName: { contains: query.keyword } },
|
||||
{ cityCode: { contains: query.keyword } },
|
||||
{ provinceCode: { contains: query.keyword } }
|
||||
]
|
||||
: undefined
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.geoCity.findMany({ where, orderBy: [{ provinceCode: 'asc' }, { cityCode: 'asc' }], take: query.take, skip: query.skip }),
|
||||
this.prisma.geoCity.count({ where })
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async importCities(items: CityInput[]): Promise<ImportResult> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
await tx.geoCity.upsert({
|
||||
where: { code: item.code },
|
||||
update: {
|
||||
provinceCode: item.provinceCode,
|
||||
provinceName: item.provinceName,
|
||||
cityCode: item.cityCode,
|
||||
cityName: item.cityName,
|
||||
cityLevel: item.cityLevel,
|
||||
status: item.status ?? 'ENABLED',
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
updatedBy: item.actorId,
|
||||
version: { increment: 1 },
|
||||
deletedAt: null
|
||||
},
|
||||
create: {
|
||||
code: item.code,
|
||||
provinceCode: item.provinceCode,
|
||||
provinceName: item.provinceName,
|
||||
cityCode: item.cityCode,
|
||||
cityName: item.cityName,
|
||||
cityLevel: item.cityLevel,
|
||||
status: item.status ?? 'ENABLED',
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
createdBy: item.actorId,
|
||||
updatedBy: item.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, 'geo_city.changed', items.length);
|
||||
});
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
|
||||
const where: Prisma.PhoneNumberSegmentWhereInput = {
|
||||
deletedAt: null,
|
||||
segment7: query.segment7 ? { startsWith: query.segment7 } : undefined,
|
||||
cityCode: query.cityCode,
|
||||
carrier: query.carrier,
|
||||
OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.phoneNumberSegment.findMany({ where, orderBy: [{ segment7: 'asc' }], take: query.take, skip: query.skip }),
|
||||
this.prisma.phoneNumberSegment.count({ where })
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult> {
|
||||
await this.ensureCities(items.map((item) => item.cityCode));
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
await tx.phoneNumberSegment.upsert({
|
||||
where: { segment7: item.segment7 },
|
||||
update: {
|
||||
cityCode: item.cityCode,
|
||||
provinceName: item.provinceName,
|
||||
cityName: item.cityName,
|
||||
carrier: item.carrier ?? 'UNKNOWN',
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
updatedBy: item.actorId,
|
||||
version: { increment: 1 },
|
||||
deletedAt: null
|
||||
},
|
||||
create: {
|
||||
segment7: item.segment7,
|
||||
cityCode: item.cityCode,
|
||||
provinceName: item.provinceName,
|
||||
cityName: item.cityName,
|
||||
carrier: item.carrier ?? 'UNKNOWN',
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
createdBy: item.actorId,
|
||||
updatedBy: item.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, 'phone_number_segment.changed', items.length);
|
||||
});
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> {
|
||||
const where: Prisma.PhoneAreaCodeWhereInput = {
|
||||
deletedAt: null,
|
||||
areaCode: query.areaCode ? { startsWith: query.areaCode } : undefined,
|
||||
cityCode: query.cityCode,
|
||||
OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.phoneAreaCode.findMany({ where, orderBy: [{ areaCode: 'asc' }], take: query.take, skip: query.skip }),
|
||||
this.prisma.phoneAreaCode.count({ where })
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult> {
|
||||
await this.ensureCities(items.map((item) => item.cityCode));
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
await tx.phoneAreaCode.upsert({
|
||||
where: { areaCode: item.areaCode },
|
||||
update: {
|
||||
cityCode: item.cityCode,
|
||||
provinceName: item.provinceName,
|
||||
cityName: item.cityName,
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
updatedBy: item.actorId,
|
||||
version: { increment: 1 },
|
||||
deletedAt: null
|
||||
},
|
||||
create: {
|
||||
areaCode: item.areaCode,
|
||||
cityCode: item.cityCode,
|
||||
provinceName: item.provinceName,
|
||||
cityName: item.cityName,
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
createdBy: item.actorId,
|
||||
updatedBy: item.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, 'phone_area_code.changed', items.length);
|
||||
});
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
|
||||
const where: Prisma.CarrierPrefixRuleWhereInput = {
|
||||
deletedAt: null,
|
||||
prefix: query.prefix ? { startsWith: query.prefix } : undefined,
|
||||
carrier: query.carrier
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.carrierPrefixRule.findMany({ where, orderBy: [{ prefix: 'asc' }], take: query.take, skip: query.skip }),
|
||||
this.prisma.carrierPrefixRule.count({ where })
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
await tx.carrierPrefixRule.upsert({
|
||||
where: { prefix: item.prefix },
|
||||
update: {
|
||||
carrier: item.carrier,
|
||||
priority: item.priority,
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
updatedBy: item.actorId,
|
||||
version: { increment: 1 },
|
||||
deletedAt: null
|
||||
},
|
||||
create: {
|
||||
prefix: item.prefix,
|
||||
carrier: item.carrier,
|
||||
priority: item.priority,
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
createdBy: item.actorId,
|
||||
updatedBy: item.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, 'carrier_prefix_rule.changed', items.length);
|
||||
});
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, eventType: string, itemCount: number): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: outboxId(),
|
||||
aggregateType: 'number_library_config',
|
||||
aggregateId: 'number-library',
|
||||
eventType,
|
||||
payload: { eventType, itemCount }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureCities(cityCodes: string[]): Promise<void> {
|
||||
const uniqueCodes = [...new Set(cityCodes)];
|
||||
const count = await this.prisma.geoCity.count({ where: { cityCode: { in: uniqueCodes }, deletedAt: null } });
|
||||
if (count !== uniqueCodes.length) {
|
||||
throw new BadRequestException({ code: 'CITY_NOT_FOUND', message: 'One or more city codes are invalid.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
NUMBER_LIBRARY_REPOSITORY,
|
||||
type AreaCodeInput,
|
||||
type CarrierPrefixInput,
|
||||
type CityInput,
|
||||
type NumberCarrier,
|
||||
type NumberLibraryRepository,
|
||||
type PageQuery,
|
||||
type PhoneSegmentInput
|
||||
} from './number-library.repository.js';
|
||||
|
||||
const MAX_IMPORT_ITEMS = 1000;
|
||||
|
||||
type QueryRecord = Record<string, unknown>;
|
||||
|
||||
@Injectable()
|
||||
export class NumberLibraryService {
|
||||
constructor(@Inject(NUMBER_LIBRARY_REPOSITORY) private readonly numberLibrary: NumberLibraryRepository) {}
|
||||
|
||||
listCities(query: QueryRecord) {
|
||||
return this.numberLibrary.listCities(this.page(query));
|
||||
}
|
||||
|
||||
importCities(body: QueryRecord, actorId?: string) {
|
||||
const items = this.items(body).map((item) => ({
|
||||
code: this.limitedString(item.code, 'code', 12),
|
||||
provinceCode: this.limitedString(item.provinceCode, 'provinceCode', 12),
|
||||
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
|
||||
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
|
||||
cityName: this.limitedString(item.cityName, 'cityName', 80),
|
||||
cityLevel: this.limitedString(item.cityLevel, 'cityLevel', 32),
|
||||
status: item.status === undefined ? 'ENABLED' : this.status(item.status),
|
||||
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
|
||||
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
|
||||
actorId
|
||||
})) satisfies CityInput[];
|
||||
return this.numberLibrary.importCities(items);
|
||||
}
|
||||
|
||||
listPhoneSegments(query: QueryRecord) {
|
||||
return this.numberLibrary.listPhoneSegments({
|
||||
...this.page(query),
|
||||
segment7: query.segment7 === undefined ? undefined : this.digits(query.segment7, 'segment7', 1, 7),
|
||||
cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12),
|
||||
carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier)
|
||||
});
|
||||
}
|
||||
|
||||
importPhoneSegments(body: QueryRecord, actorId?: string) {
|
||||
const items = this.items(body).map((item) => ({
|
||||
segment7: this.digits(item.segment7, 'segment7', 7, 7),
|
||||
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
|
||||
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
|
||||
cityName: this.limitedString(item.cityName, 'cityName', 80),
|
||||
carrier: item.carrier === undefined ? 'UNKNOWN' : this.carrier(item.carrier),
|
||||
source: this.optionalLimitedString(item.source, 'source', 120),
|
||||
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
|
||||
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
|
||||
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
|
||||
actorId
|
||||
})) satisfies PhoneSegmentInput[];
|
||||
return this.numberLibrary.importPhoneSegments(items);
|
||||
}
|
||||
|
||||
listAreaCodes(query: QueryRecord) {
|
||||
return this.numberLibrary.listAreaCodes({
|
||||
...this.page(query),
|
||||
areaCode: query.areaCode === undefined ? undefined : this.digits(query.areaCode, 'areaCode', 2, 8),
|
||||
cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12)
|
||||
});
|
||||
}
|
||||
|
||||
importAreaCodes(body: QueryRecord, actorId?: string) {
|
||||
const items = this.items(body).map((item) => ({
|
||||
areaCode: this.digits(item.areaCode, 'areaCode', 2, 8),
|
||||
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
|
||||
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
|
||||
cityName: this.limitedString(item.cityName, 'cityName', 80),
|
||||
source: this.optionalLimitedString(item.source, 'source', 120),
|
||||
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
|
||||
actorId
|
||||
})) satisfies AreaCodeInput[];
|
||||
return this.numberLibrary.importAreaCodes(items);
|
||||
}
|
||||
|
||||
listCarrierPrefixRules(query: QueryRecord) {
|
||||
return this.numberLibrary.listCarrierPrefixRules({
|
||||
...this.page(query),
|
||||
prefix: query.prefix === undefined ? undefined : this.digits(query.prefix, 'prefix', 1, 4),
|
||||
carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier)
|
||||
});
|
||||
}
|
||||
|
||||
importCarrierPrefixRules(body: QueryRecord, actorId?: string) {
|
||||
const items = this.items(body).map((item) => ({
|
||||
prefix: this.digits(item.prefix, 'prefix', 3, 4),
|
||||
carrier: this.carrier(item.carrier),
|
||||
priority: this.integer(item.priority ?? 100, 'priority', 1, 10_000),
|
||||
source: this.optionalLimitedString(item.source, 'source', 120),
|
||||
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
|
||||
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
|
||||
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
|
||||
actorId
|
||||
})) satisfies CarrierPrefixInput[];
|
||||
return this.numberLibrary.importCarrierPrefixRules(items);
|
||||
}
|
||||
|
||||
private page(query: QueryRecord): PageQuery {
|
||||
return {
|
||||
keyword: query.keyword === undefined ? undefined : this.limitedString(query.keyword, 'keyword', 80),
|
||||
take: this.integer(query.take ?? 50, 'take', 1, 500),
|
||||
skip: this.integer(query.skip ?? 0, 'skip', 0, 1_000_000)
|
||||
};
|
||||
}
|
||||
|
||||
private items(body: QueryRecord): QueryRecord[] {
|
||||
if (!Array.isArray(body.items) || body.items.length === 0 || body.items.length > MAX_IMPORT_ITEMS) {
|
||||
throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: `items must contain 1-${MAX_IMPORT_ITEMS} records.` });
|
||||
}
|
||||
if (!body.items.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
|
||||
throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: 'items must be objects.' });
|
||||
}
|
||||
return body.items as QueryRecord[];
|
||||
}
|
||||
|
||||
private limitedString(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 optionalLimitedString(value: unknown, field: string, maxLength: number): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private digits(value: unknown, field: string, minLength: number, maxLength: number): string {
|
||||
const text = this.limitedString(value, field, maxLength);
|
||||
if (!new RegExp(`^\\d{${minLength},${maxLength}}$`).test(text)) {
|
||||
throw new BadRequestException({ code: 'DIGITS_INVALID', message: `${field} must contain ${minLength}-${maxLength} digits.` });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private integer(value: unknown, field: string, min: number, max: number): number {
|
||||
const number = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN;
|
||||
if (!Number.isInteger(number) || number < min || number > max) {
|
||||
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
private carrier(value: unknown): NumberCarrier {
|
||||
if (value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' || value === 'UNKNOWN') {
|
||||
return value;
|
||||
}
|
||||
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
|
||||
}
|
||||
|
||||
private status(value: unknown): 'ENABLED' | 'DISABLED' {
|
||||
if (value === 'ENABLED' || value === 'DISABLED') {
|
||||
return value;
|
||||
}
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
|
||||
private optionalDate(value: unknown, field: string): Date | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return date;
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,10 @@ export class PrismaRechargesRepository implements RechargesRepository {
|
||||
}
|
||||
|
||||
private async listCustomerRecharges(accountId: string | undefined, take: number, skip: number): Promise<[RechargeSummary[], number]> {
|
||||
const where = accountId ? { customerId: accountId } : {};
|
||||
const where: Prisma.CustomerRechargeWhereInput = {
|
||||
...(accountId ? { customerId: accountId } : {}),
|
||||
NOT: [{ createdBy: 'worker-cdr' }, { idempotencyKey: { startsWith: 'cdr:' } }, { remark: { startsWith: 'CDR_CHARGE:' } }]
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.customerRecharge.findMany({
|
||||
where,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } 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';
|
||||
@@ -29,6 +29,13 @@ export class RolesController {
|
||||
return this.rolesService.updateRole(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete('roles/:id')
|
||||
@RequirePermissions('roles.manage')
|
||||
@AuditAction({ module: 'roles', action: 'delete', objectType: 'role', objectIdParam: 'id' })
|
||||
removeRole(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.rolesService.removeRole(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@RequirePermissions('roles.view')
|
||||
listPermissions() {
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface RolesRepository {
|
||||
listPermissions(): Promise<PermissionSummary[]>;
|
||||
createRole(input: CreateRoleInput): Promise<RoleSummary>;
|
||||
updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary>;
|
||||
softDeleteRole(roleId: string, actorId?: string): Promise<RoleSummary>;
|
||||
}
|
||||
|
||||
export const ROLES_REPOSITORY = Symbol('ROLES_REPOSITORY');
|
||||
@@ -169,6 +170,54 @@ export class PrismaRolesRepository implements RolesRepository {
|
||||
return this.toSummary(role);
|
||||
}
|
||||
|
||||
async softDeleteRole(roleIdValue: string, actorId?: string): Promise<RoleSummary> {
|
||||
const role = await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.role.findUnique({ where: { id: roleIdValue } });
|
||||
if (!existing || existing.deletedAt) {
|
||||
throw new NotFoundException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' });
|
||||
}
|
||||
|
||||
if (existing.builtIn) {
|
||||
throw new ForbiddenException({
|
||||
code: 'BUILT_IN_ROLE_PROTECTED',
|
||||
message: 'Built-in roles are protected.'
|
||||
});
|
||||
}
|
||||
|
||||
const linkedUsers = await tx.userRole.count({
|
||||
where: {
|
||||
roleId: roleIdValue,
|
||||
user: { deletedAt: null }
|
||||
}
|
||||
});
|
||||
|
||||
if (linkedUsers > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'ROLE_HAS_USERS',
|
||||
message: 'Role with active users cannot be deleted.'
|
||||
});
|
||||
}
|
||||
|
||||
return tx.role.update({
|
||||
where: { id: roleIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { userRoles: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(role);
|
||||
}
|
||||
|
||||
private async ensurePermissions(permissionIds: string[]): Promise<void> {
|
||||
const uniquePermissionIds = [...new Set(permissionIds)];
|
||||
const count = await this.prisma.permission.count({
|
||||
|
||||
@@ -49,6 +49,10 @@ export class RolesService {
|
||||
return this.roles.updateRole(roleId, input);
|
||||
}
|
||||
|
||||
removeRole(roleId: string, actorId?: string) {
|
||||
return this.roles.softDeleteRole(roleId, actorId);
|
||||
}
|
||||
|
||||
private requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
|
||||
@@ -83,6 +83,10 @@ class MemoryUsersRepository implements UsersRepository {
|
||||
return this.summary(userId, 'operator', ['ROLE_SUPER_ADMIN'], true);
|
||||
}
|
||||
|
||||
async softDelete(userId: string): Promise<UserSummary> {
|
||||
return this.summary(userId, 'operator', []);
|
||||
}
|
||||
|
||||
private summary(id: string, username: string, roleIds: string[], requirePasswordChange = false): UserSummary {
|
||||
return {
|
||||
id,
|
||||
@@ -125,6 +129,10 @@ class MemoryRolesRepository implements RolesRepository {
|
||||
return this.role(roleId, roleId === 'ROLE_SUPER_ADMIN', input.permissionIds ?? ['users.manage']);
|
||||
}
|
||||
|
||||
async softDeleteRole(roleId: string): Promise<RoleSummary> {
|
||||
return this.role(roleId, false, []);
|
||||
}
|
||||
|
||||
private role(id: string, builtIn: boolean, permissionIds: string[], name = 'role'): RoleSummary {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from './audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from './security/identity.repository.js';
|
||||
import type { CurrentUser } from './security/security.metadata.js';
|
||||
import {
|
||||
ROLES_REPOSITORY,
|
||||
type CreateRoleInput,
|
||||
type PermissionSummary,
|
||||
type RoleSummary,
|
||||
type RolesRepository,
|
||||
type UpdateRoleInput
|
||||
} from './roles/roles.repository.js';
|
||||
import {
|
||||
USERS_REPOSITORY,
|
||||
type CreateUserInput,
|
||||
type UpdateUserInput,
|
||||
type UserSummary,
|
||||
type UsersRepository
|
||||
} from './users/users.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryRolesRepository implements RolesRepository {
|
||||
private readonly roles = new Map<string, RoleSummary>();
|
||||
private readonly permissions: PermissionSummary[] = [{ id: 'users.manage', module: 'users', action: 'manage', description: null }];
|
||||
|
||||
constructor() {
|
||||
this.roles.set('rol_builtin', this.summary({ id: 'rol_builtin', name: '系统管理员', builtIn: true, userCount: 1 }));
|
||||
this.roles.set('rol_used', this.summary({ id: 'rol_used', name: '运营', userCount: 1 }));
|
||||
this.roles.set('rol_blocked', this.summary({ id: 'rol_blocked', name: '仍在使用', userCount: 1 }));
|
||||
this.roles.set('rol_empty', this.summary({ id: 'rol_empty', name: '空角色', userCount: 0 }));
|
||||
}
|
||||
|
||||
async listRoles(): Promise<RoleSummary[]> {
|
||||
return [...this.roles.values()];
|
||||
}
|
||||
|
||||
async listPermissions(): Promise<PermissionSummary[]> {
|
||||
return this.permissions;
|
||||
}
|
||||
|
||||
async createRole(input: CreateRoleInput): Promise<RoleSummary> {
|
||||
const role = this.summary({ id: 'rol_created', name: input.name, description: input.description ?? null });
|
||||
this.roles.set(role.id, role);
|
||||
return role;
|
||||
}
|
||||
|
||||
async updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary> {
|
||||
const current = this.roles.get(roleId) ?? this.summary({ id: roleId, name: 'Missing Role' });
|
||||
const role: RoleSummary = {
|
||||
...current,
|
||||
name: input.name ?? current.name,
|
||||
description: input.description === undefined ? current.description : input.description,
|
||||
status: input.status ?? current.status,
|
||||
permissionIds: input.permissionIds ?? current.permissionIds,
|
||||
updatedAt: new Date('2026-06-24T08:00:00.000Z')
|
||||
};
|
||||
this.roles.set(role.id, role);
|
||||
return role;
|
||||
}
|
||||
|
||||
async softDeleteRole(roleId: string): Promise<RoleSummary> {
|
||||
const role = this.roles.get(roleId);
|
||||
if (!role) {
|
||||
throw new BadRequestException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' });
|
||||
}
|
||||
if (role.builtIn) {
|
||||
throw new ForbiddenException({ code: 'BUILT_IN_ROLE_PROTECTED', message: 'Built-in roles are protected.' });
|
||||
}
|
||||
if (role.userCount > 0) {
|
||||
throw new BadRequestException({ code: 'ROLE_HAS_USERS', message: 'Role with active users cannot be deleted.' });
|
||||
}
|
||||
const deleted: RoleSummary = { ...role, status: 'DISABLED', updatedAt: new Date('2026-06-24T08:00:00.000Z') };
|
||||
this.roles.delete(roleId);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
decrementUserCount(roleIds: string[]) {
|
||||
for (const roleId of roleIds) {
|
||||
const role = this.roles.get(roleId);
|
||||
if (role) {
|
||||
this.roles.set(roleId, { ...role, userCount: Math.max(0, role.userCount - 1) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private summary(input: { id: string; name: string; description?: string | null; builtIn?: boolean; userCount?: number }): RoleSummary {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
builtIn: input.builtIn ?? false,
|
||||
status: 'ENABLED',
|
||||
permissionIds: ['users.manage'],
|
||||
userCount: input.userCount ?? 0,
|
||||
createdAt: new Date('2026-06-24T07:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T07:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryUsersRepository implements UsersRepository {
|
||||
private readonly users = new Map<string, UserSummary>();
|
||||
|
||||
constructor(private readonly roles: MemoryRolesRepository) {
|
||||
this.users.set('usr_seed', this.summary({ id: 'usr_seed', username: 'seed', displayName: 'Seed User', roleIds: ['rol_used'], roles: ['运营'] }));
|
||||
}
|
||||
|
||||
async list(): Promise<UserSummary[]> {
|
||||
return [...this.users.values()];
|
||||
}
|
||||
|
||||
async create(input: CreateUserInput): Promise<UserSummary> {
|
||||
const user = this.summary({ id: 'usr_created', username: input.username, displayName: input.displayName, roleIds: input.roleIds });
|
||||
this.users.set(user.id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(userId: string, input: UpdateUserInput): Promise<UserSummary> {
|
||||
const current = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
|
||||
const updated: UserSummary = {
|
||||
...current,
|
||||
displayName: input.displayName ?? current.displayName,
|
||||
phone: input.phone === undefined ? current.phone : input.phone,
|
||||
email: input.email === undefined ? current.email : input.email,
|
||||
status: input.status ?? current.status,
|
||||
roleIds: input.roleIds ?? current.roleIds,
|
||||
updatedAt: new Date('2026-06-24T08:00:00.000Z')
|
||||
};
|
||||
this.users.set(userId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async resetPassword(userId: string): Promise<UserSummary> {
|
||||
return this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
|
||||
}
|
||||
|
||||
async softDelete(userId: string): Promise<UserSummary> {
|
||||
const user = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
|
||||
const deleted: UserSummary = { ...user, status: 'DISABLED', roleIds: [], roles: [], updatedAt: new Date('2026-06-24T08:00:00.000Z') };
|
||||
this.users.delete(userId);
|
||||
this.roles.decrementUserCount(user.roleIds);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private summary(input: { id: string; username: string; displayName: string; roleIds?: string[]; roles?: string[] }): UserSummary {
|
||||
return {
|
||||
id: input.id,
|
||||
username: input.username,
|
||||
displayName: input.displayName,
|
||||
phone: null,
|
||||
email: null,
|
||||
status: 'ENABLED',
|
||||
requirePasswordChange: false,
|
||||
lastLoginAt: null,
|
||||
roles: input.roles ?? [],
|
||||
roleIds: input.roleIds ?? [],
|
||||
createdAt: new Date('2026-06-24T07:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T07:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S40 users and roles delete API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
const roles = new MemoryRolesRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['users.view', 'users.manage', 'roles.view', 'roles.manage'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['users.view', 'roles.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('./app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(ROLES_REPOSITORY)
|
||||
.useValue(roles)
|
||||
.overrideProvider(USERS_REPOSITORY)
|
||||
.useValue(new MemoryUsersRepository(roles))
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('rejects delete operations without manage permissions', async () => {
|
||||
await request(app.getHttpServer()).delete('/api/v2/users/usr_seed').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403);
|
||||
await request(app.getHttpServer()).delete('/api/v2/roles/rol_empty').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403);
|
||||
});
|
||||
|
||||
it('soft deletes users with an audit entry', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v2/users/usr_seed')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ id: 'usr_seed', status: 'DISABLED', roleIds: [] });
|
||||
});
|
||||
|
||||
expect(audit.entries.some((entry) => entry.module === 'users' && entry.action === 'delete' && entry.objectId === 'usr_seed')).toBe(true);
|
||||
});
|
||||
|
||||
it('protects built-in and still-used roles, then deletes an empty custom role with audit', async () => {
|
||||
await request(app.getHttpServer()).delete('/api/v2/roles/rol_builtin').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(403);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v2/roles/rol_blocked')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.expect(400)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ code: 'ROLE_HAS_USERS' });
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v2/roles/rol_empty')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ id: 'rol_empty', status: 'DISABLED' });
|
||||
});
|
||||
|
||||
expect(audit.entries.some((entry) => entry.module === 'roles' && entry.action === 'delete' && entry.objectId === 'rol_empty')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } 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';
|
||||
@@ -35,4 +35,11 @@ export class UsersController {
|
||||
resetPassword(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.usersService.resetPassword(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('users.manage')
|
||||
@AuditAction({ module: 'users', action: 'delete', objectType: 'user', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.usersService.remove(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface UsersRepository {
|
||||
create(input: CreateUserInput): Promise<UserSummary>;
|
||||
update(userId: string, input: UpdateUserInput): Promise<UserSummary>;
|
||||
resetPassword(userId: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise<UserSummary>;
|
||||
softDelete(userId: string, actorId?: string): Promise<UserSummary>;
|
||||
}
|
||||
|
||||
export const USERS_REPOSITORY = Symbol('USERS_REPOSITORY');
|
||||
@@ -167,7 +168,13 @@ export class PrismaUsersRepository implements UsersRepository {
|
||||
}
|
||||
|
||||
async resetPassword(userIdValue: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise<UserSummary> {
|
||||
const user = await this.prisma.user.update({
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.user.findUnique({ where: { id: userIdValue } });
|
||||
if (!exists || exists.deletedAt) {
|
||||
throw new NotFoundException({ code: 'USER_NOT_FOUND', message: 'User not found.' });
|
||||
}
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: userIdValue },
|
||||
data: {
|
||||
passwordHash,
|
||||
@@ -186,6 +193,37 @@ export class PrismaUsersRepository implements UsersRepository {
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(user);
|
||||
}
|
||||
|
||||
async softDelete(userIdValue: string, actorId?: string): Promise<UserSummary> {
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.user.findUnique({ where: { id: userIdValue } });
|
||||
if (!exists || exists.deletedAt) {
|
||||
throw new NotFoundException({ code: 'USER_NOT_FOUND', message: 'User not found.' });
|
||||
}
|
||||
|
||||
await tx.userRole.deleteMany({ where: { userId: userIdValue } });
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: userIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(user);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ export class UsersService {
|
||||
return this.users.resetPassword(userId, await hashPasswordArgon2id(this.password(body.password)), PASSWORD_ALGO_ARGON2ID, actorId);
|
||||
}
|
||||
|
||||
remove(userId: string, actorId?: string): Promise<UserSummary> {
|
||||
return this.users.softDelete(userId, actorId);
|
||||
}
|
||||
|
||||
private requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
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';
|
||||
@@ -48,4 +48,11 @@ export class VendorGatewaysController {
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorGatewaysService.disable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('vendor_gateways.manage')
|
||||
@AuditAction({ module: 'vendor_gateways', action: 'delete', objectType: 'vendor_gateway', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorGatewaysService.remove(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,14 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||
return this.update(gatewayId, { status });
|
||||
}
|
||||
|
||||
async softDelete(gatewayId: string): Promise<VendorGatewaySummary> {
|
||||
const current = await this.get(gatewayId);
|
||||
const deleted = this.summary({ ...current, status: 'DISABLED' });
|
||||
this.gateways.delete(gatewayId);
|
||||
this.outboxEvents += 1;
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private summary(input: Partial<VendorGatewaySummary> & { id: string; name: string }): VendorGatewaySummary {
|
||||
const cycleRate = input.cycleRate ?? '0.000000';
|
||||
const billingCycleSec = input.billingCycleSec ?? 60;
|
||||
@@ -313,5 +321,7 @@ describe('S16 vendor gateways API', () => {
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).delete('/api/v2/vendor-gateways/vgw_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendor_gateways' && entry.action === 'delete' && entry.result === 'SUCCESS')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
@@ -100,6 +100,7 @@ export interface VendorGatewaysRepository {
|
||||
create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||
update(gatewayId: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||
setStatus(gatewayId: string, status: VendorGatewayStatus, actorId?: string): Promise<VendorGatewaySummary>;
|
||||
softDelete(gatewayId: string, actorId?: string): Promise<VendorGatewaySummary>;
|
||||
}
|
||||
|
||||
export const VENDOR_GATEWAYS_REPOSITORY = Symbol('VENDOR_GATEWAYS_REPOSITORY');
|
||||
@@ -244,6 +245,38 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
async softDelete(gatewayIdValue: string, actorId?: string): Promise<VendorGatewaySummary> {
|
||||
await this.findActiveOrThrow(gatewayIdValue);
|
||||
const linkedLineGroups = await this.prisma.landingLineGroupItem.count({
|
||||
where: {
|
||||
vendorGatewayId: gatewayIdValue,
|
||||
lineGroup: { deletedAt: null }
|
||||
}
|
||||
});
|
||||
if (linkedLineGroups > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'VENDOR_GATEWAY_IN_LINE_GROUP',
|
||||
message: 'Vendor gateway referenced by active line groups cannot be deleted.'
|
||||
});
|
||||
}
|
||||
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.vendorGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, deleted.id, 'vendor_gateway.deleted');
|
||||
return deleted;
|
||||
});
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
private async ensureVendor(vendorId: string): Promise<void> {
|
||||
const vendor = await this.prisma.vendor.findUnique({
|
||||
where: { id: vendorId },
|
||||
|
||||
@@ -121,6 +121,10 @@ export class VendorGatewaysService {
|
||||
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
remove(gatewayId: string, actorId?: string): Promise<VendorGatewaySummary> {
|
||||
return this.gateways.softDelete(gatewayId, actorId);
|
||||
}
|
||||
|
||||
private limitedString(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.` });
|
||||
|
||||
@@ -18,12 +18,20 @@ export interface RuntimeConfig {
|
||||
redis: {
|
||||
url: string;
|
||||
};
|
||||
activeCalls: {
|
||||
sshHost: string;
|
||||
sshConfig?: string;
|
||||
remoteCommand: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
auth: {
|
||||
accessTokenSecret: string;
|
||||
accessTokenTtlSeconds: number;
|
||||
refreshTokenTtlDays: number;
|
||||
lockMaxFailures: number;
|
||||
lockWindowSeconds: number;
|
||||
loginThrottleMaxFailures: number;
|
||||
loginThrottleWindowSeconds: number;
|
||||
cookieSecure: boolean;
|
||||
tokenIssuer: string;
|
||||
tokenAudience: string;
|
||||
@@ -39,6 +47,10 @@ export const validationSchema = Joi.object({
|
||||
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
|
||||
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
|
||||
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
|
||||
ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'),
|
||||
ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(),
|
||||
ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'),
|
||||
ACTIVE_CALLS_TIMEOUT_MS: Joi.number().integer().min(1000).max(30000).default(5000),
|
||||
AUTH_ACCESS_TOKEN_SECRET: Joi.string().min(32).when('NODE_ENV', {
|
||||
is: 'production',
|
||||
then: Joi.required(),
|
||||
@@ -48,12 +60,16 @@ export const validationSchema = Joi.object({
|
||||
AUTH_REFRESH_TOKEN_TTL_DAYS: Joi.number().integer().min(1).max(30).default(7),
|
||||
AUTH_LOCK_MAX_FAILURES: Joi.number().integer().min(3).max(20).default(5),
|
||||
AUTH_LOCK_WINDOW_SECONDS: Joi.number().integer().min(60).max(86_400).default(900),
|
||||
AUTH_COOKIE_SECURE: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||
AUTH_LOGIN_THROTTLE_MAX_FAILURES: Joi.number().integer().min(3).max(100).default(10),
|
||||
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS: Joi.number().integer().min(60).max(86_400).default(300),
|
||||
AUTH_COOKIE_SECURE: Joi.any().default(true),
|
||||
AUTH_TOKEN_ISSUER: Joi.string().default('lisglosips-api'),
|
||||
AUTH_TOKEN_AUDIENCE: Joi.string().default('lisglosips-web')
|
||||
});
|
||||
|
||||
export function appConfig(): RuntimeConfig {
|
||||
const cookieSecureValue = (process.env.AUTH_COOKIE_SECURE ?? 'true').trim().replace(/^['"]|['"]$/g, '').toLowerCase();
|
||||
|
||||
return {
|
||||
service: {
|
||||
name: process.env.LISGLOSIPS_SERVICE_NAME ?? 'api'
|
||||
@@ -72,13 +88,21 @@ export function appConfig(): RuntimeConfig {
|
||||
redis: {
|
||||
url: process.env.REDIS_URL ?? ''
|
||||
},
|
||||
activeCalls: {
|
||||
sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a',
|
||||
sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined,
|
||||
remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control',
|
||||
timeoutMs: Number(process.env.ACTIVE_CALLS_TIMEOUT_MS ?? 5000)
|
||||
},
|
||||
auth: {
|
||||
accessTokenSecret: process.env.AUTH_ACCESS_TOKEN_SECRET ?? 'dev-only-change-this-auth-secret-32-bytes-min',
|
||||
accessTokenTtlSeconds: Number(process.env.AUTH_ACCESS_TOKEN_TTL_SECONDS ?? 900),
|
||||
refreshTokenTtlDays: Number(process.env.AUTH_REFRESH_TOKEN_TTL_DAYS ?? 7),
|
||||
lockMaxFailures: Number(process.env.AUTH_LOCK_MAX_FAILURES ?? 5),
|
||||
lockWindowSeconds: Number(process.env.AUTH_LOCK_WINDOW_SECONDS ?? 900),
|
||||
cookieSecure: (process.env.AUTH_COOKIE_SECURE ?? 'true') !== 'false',
|
||||
loginThrottleMaxFailures: Number(process.env.AUTH_LOGIN_THROTTLE_MAX_FAILURES ?? 10),
|
||||
loginThrottleWindowSeconds: Number(process.env.AUTH_LOGIN_THROTTLE_WINDOW_SECONDS ?? 300),
|
||||
cookieSecure: cookieSecureValue !== 'false' && cookieSecureValue !== '0',
|
||||
tokenIssuer: process.env.AUTH_TOKEN_ISSUER ?? 'lisglosips-api',
|
||||
tokenAudience: process.env.AUTH_TOKEN_AUDIENCE ?? 'lisglosips-web'
|
||||
}
|
||||
|
||||
+1245
-103
File diff suppressed because it is too large
Load Diff
+88
-1
@@ -1,6 +1,18 @@
|
||||
const API_BASE = (import.meta.env.VITE_LISGLOSIPS_API_BASE || '/api/v2').replace(/\/$/, '');
|
||||
const ACCESS_TOKEN_KEY = 'lisglosips.accessToken';
|
||||
|
||||
export function getAccessToken() {
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setAccessToken(token) {
|
||||
if (token) {
|
||||
window.localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, { status, code } = {}) {
|
||||
super(message);
|
||||
@@ -55,12 +67,39 @@ function idempotencyKey(scope) {
|
||||
return `${scope}:${random}`;
|
||||
}
|
||||
|
||||
function queryString(params = {}) {
|
||||
const entries = Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '' && value !== 'all');
|
||||
return entries.length ? `?${new URLSearchParams(Object.fromEntries(entries))}` : '';
|
||||
}
|
||||
|
||||
export const api = {
|
||||
captcha: () => request('/auth/captcha'),
|
||||
login: async (body) => {
|
||||
const response = await request('/auth/login', { method: 'POST', body: jsonBody(body) });
|
||||
setAccessToken(response.accessToken);
|
||||
return response;
|
||||
},
|
||||
refresh: async () => {
|
||||
const response = await request('/auth/refresh', { method: 'POST' });
|
||||
setAccessToken(response.accessToken);
|
||||
return response;
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
await request('/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
setAccessToken('');
|
||||
}
|
||||
},
|
||||
dashboardSummary: () => request('/dashboard/summary'),
|
||||
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }) => request(`/dashboard/trends?${new URLSearchParams(params)}`),
|
||||
activeCalls: () => request('/active-calls'),
|
||||
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
|
||||
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 100, ...params })}`),
|
||||
customers: () => request('/customers'),
|
||||
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
|
||||
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
deleteCustomer: (id) => request(`/customers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
rechargeCustomer: (id, body) =>
|
||||
request(`/customers/${encodeURIComponent(id)}/recharges`, {
|
||||
method: 'POST',
|
||||
@@ -69,20 +108,68 @@ export const api = {
|
||||
vendors: () => request('/vendors'),
|
||||
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
|
||||
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
rechargeVendor: (id, body) =>
|
||||
request(`/vendors/${encodeURIComponent(id)}/recharges`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
|
||||
}),
|
||||
customerGateways: () => request('/customer-gateways'),
|
||||
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'),
|
||||
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' }),
|
||||
landingLineGroups: () => request('/landing-line-groups'),
|
||||
deleteLandingLineGroup: (id) => request(`/landing-line-groups/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
recharges: () => request('/recharges?take=100'),
|
||||
users: () => request('/users'),
|
||||
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
roles: () => request('/roles'),
|
||||
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
auditLogs: () => request('/audit-logs?take=100'),
|
||||
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 })}`),
|
||||
importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 100, ...params })}`),
|
||||
importNumberLibraryAreaCodes: (items) => request('/number-library/area-codes/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 100, ...params })}`),
|
||||
importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
};
|
||||
|
||||
export function explainApiError(error) {
|
||||
if (error instanceof ApiError && error.code === 'AUTH_CAPTCHA_INVALID') {
|
||||
return '验证码错误或已过期,请重新输入。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'AUTH_INVALID_CREDENTIALS') {
|
||||
return '用户名、密码或验证码不正确。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'CUSTOMER_HAS_GATEWAYS') {
|
||||
return '该客户仍有关联客户网关,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'VENDOR_HAS_GATEWAYS') {
|
||||
return '该供应商仍有关联落地网关,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'LINE_GROUP_IN_USE') {
|
||||
return '该落地线路组仍被客户网关使用,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'VENDOR_GATEWAY_IN_LINE_GROUP') {
|
||||
return '该落地网关仍被落地线路组引用,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'ROLE_HAS_USERS') {
|
||||
return '该角色仍有关联用户,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'BUILT_IN_ROLE_PROTECTED') {
|
||||
return '系统内置角色受保护,不能删除或修改关键权限。';
|
||||
}
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return 'API 已启用鉴权,请先通过登录接口获取会话或在同源环境使用有效 Cookie。';
|
||||
return '登录状态已失效,请重新登录。';
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 429) {
|
||||
return '登录尝试过于频繁,请稍后再试。';
|
||||
}
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
@@ -58,6 +58,80 @@ button:disabled {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(241, 242, 245, 0.96)),
|
||||
var(--page);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
width: min(100%, 420px);
|
||||
padding: 28px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-brand h1 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.login-brand span {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 132px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 132px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.captcha-image:hover {
|
||||
border-color: var(--selected);
|
||||
}
|
||||
|
||||
.captcha-image img {
|
||||
display: block;
|
||||
width: 132px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.prototype-app {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
@@ -668,6 +742,11 @@ button:disabled {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -759,6 +838,12 @@ button:disabled {
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.proto-table .table-cell-compact {
|
||||
max-width: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.proto-table tbody tr {
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
@@ -850,6 +935,80 @@ button:disabled {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cdr-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cdr-detail-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 42px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cdr-detail-hero div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cdr-detail-hero span,
|
||||
.cdr-detail-section h3 {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.cdr-detail-hero strong {
|
||||
overflow: hidden;
|
||||
color: var(--brand);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cdr-detail-hero svg {
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 9px;
|
||||
color: var(--selected);
|
||||
background: var(--selected-soft);
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.cdr-detail-strip,
|
||||
.cdr-detail-grid,
|
||||
.cdr-timeline {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cdr-detail-strip {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cdr-detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cdr-timeline {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cdr-detail-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rank-list div,
|
||||
.ops-row {
|
||||
display: flex;
|
||||
@@ -1444,6 +1603,12 @@ button:disabled {
|
||||
border-color: #b9e8c7;
|
||||
}
|
||||
|
||||
.ui-alert-danger {
|
||||
color: #991b1b;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -1594,10 +1759,19 @@ button:disabled {
|
||||
.match-grid,
|
||||
.option-grid,
|
||||
.rate-config-fields,
|
||||
.cdr-detail-hero,
|
||||
.cdr-detail-strip,
|
||||
.cdr-detail-grid,
|
||||
.cdr-timeline,
|
||||
.code-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cdr-detail-hero svg {
|
||||
justify-self: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.inline-control,
|
||||
.time-range,
|
||||
.prefix-rule {
|
||||
@@ -1615,6 +1789,15 @@ button:disabled {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.captcha-image,
|
||||
.captcha-image img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ import { Prisma } from '@lisglosips/database';
|
||||
import type { ParsedCdrStreamEvent } from '@lisglosips/redis';
|
||||
|
||||
import { calculateCycleCharge } from './billing.js';
|
||||
import { CdrRatingService, type CdrRatingStore, type CdrRatingTransaction, type CreateCustomerChargeInput, type CreateRatedInput } from './rating.js';
|
||||
import { CdrRatingService, 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') };
|
||||
charges: CreateCustomerChargeInput[] = [];
|
||||
|
||||
async transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T> {
|
||||
return operation(this);
|
||||
@@ -53,10 +52,6 @@ class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
|
||||
}
|
||||
}
|
||||
|
||||
async createCustomerCharge(input: CreateCustomerChargeInput): Promise<void> {
|
||||
this.charges.push(input);
|
||||
}
|
||||
|
||||
async updateCustomerBalance(_customerId: string, balance: Prisma.Decimal): Promise<void> {
|
||||
this.customer.balance = balance;
|
||||
}
|
||||
@@ -78,6 +73,11 @@ function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEv
|
||||
source_ip: '100.93.185.30',
|
||||
caller: '1001',
|
||||
callee: '13800138000',
|
||||
callee_city_code: '340100',
|
||||
callee_city_name: '合肥市',
|
||||
callee_province_name: '安徽省',
|
||||
callee_operator: 'MOBILE',
|
||||
callee_number_type: 'MOBILE',
|
||||
vendor_id: 'ven_1',
|
||||
vendor_gateway_id: 'vgw_1',
|
||||
line_group_id: 'lg_1',
|
||||
@@ -105,7 +105,7 @@ describe('S23 CDR rating', () => {
|
||||
expect(charge.amount.toFixed(6)).toBe('0.060000');
|
||||
});
|
||||
|
||||
it('rates a successful CDR and deducts customer balance once', async () => {
|
||||
it('rates a successful CDR and deducts customer balance once without recharge ledger records', async () => {
|
||||
const store = new MemoryStore();
|
||||
const service = new CdrRatingService(store);
|
||||
|
||||
@@ -121,8 +121,6 @@ describe('S23 CDR rating', () => {
|
||||
});
|
||||
expect(duplicate.outcome).toBe('duplicate');
|
||||
expect(store.customer.balance.toFixed(6)).toBe('9.940000');
|
||||
expect(store.charges).toHaveLength(1);
|
||||
expect(store.charges[0]?.amount.toFixed(6)).toBe('-0.060000');
|
||||
});
|
||||
|
||||
it('skips failed or zero-duration CDRs without balance changes', async () => {
|
||||
@@ -134,6 +132,5 @@ describe('S23 CDR rating', () => {
|
||||
expect(result.outcome).toBe('skipped');
|
||||
expect(result.customerFee).toBe('0.000000');
|
||||
expect(store.customer.balance.toFixed(6)).toBe('10.000000');
|
||||
expect(store.charges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,6 @@ export interface CdrRatingTransaction {
|
||||
lockCustomer(customerId: string): Promise<LockedCustomer | null>;
|
||||
createRated(input: CreateRatedInput): Promise<StoredRatedCdr>;
|
||||
markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise<void>;
|
||||
createCustomerCharge(input: CreateCustomerChargeInput): Promise<void>;
|
||||
updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -67,16 +66,6 @@ export interface CreateRatedInput {
|
||||
vendorRate: Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export interface CreateCustomerChargeInput {
|
||||
id: string;
|
||||
customerId: string;
|
||||
rawCdrId: string;
|
||||
eventId: string;
|
||||
amount: Prisma.Decimal;
|
||||
beforeBalance: Prisma.Decimal;
|
||||
afterBalance: Prisma.Decimal;
|
||||
}
|
||||
|
||||
export class CdrRatingService {
|
||||
constructor(private readonly store: CdrRatingStore) {}
|
||||
|
||||
@@ -144,15 +133,6 @@ export class CdrRatingService {
|
||||
cycleRate: vendorGateway.cycleRate.toFixed(6)
|
||||
}
|
||||
});
|
||||
await tx.createCustomerCharge({
|
||||
id: prefixedId('cdrchg'),
|
||||
customerId: customer.id,
|
||||
rawCdrId: raw.id,
|
||||
eventId: event.event_id,
|
||||
amount: customerFee.negated().toDecimalPlaces(6),
|
||||
beforeBalance: customer.balance,
|
||||
afterBalance
|
||||
});
|
||||
await tx.updateCustomerBalance(customer.id, afterBalance);
|
||||
await tx.markRawStatus(raw.id, 'RATED');
|
||||
|
||||
@@ -202,6 +182,11 @@ class PrismaCdrRatingTransaction implements CdrRatingTransaction {
|
||||
sourceIp: emptyToNull(event.source_ip),
|
||||
caller: event.caller || 'unknown',
|
||||
callee: event.callee || 'unknown',
|
||||
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),
|
||||
@@ -253,22 +238,6 @@ class PrismaCdrRatingTransaction implements CdrRatingTransaction {
|
||||
});
|
||||
}
|
||||
|
||||
async createCustomerCharge(input: CreateCustomerChargeInput): Promise<void> {
|
||||
await this.tx.customerRecharge.create({
|
||||
data: {
|
||||
id: input.id,
|
||||
customerId: input.customerId,
|
||||
amount: input.amount,
|
||||
beforeBalance: input.beforeBalance,
|
||||
afterBalance: input.afterBalance,
|
||||
idempotencyKey: `cdr:${input.eventId}`,
|
||||
remark: `CDR_CHARGE:${input.rawCdrId}`,
|
||||
status: 'SUCCEEDED',
|
||||
createdBy: 'worker-cdr'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise<void> {
|
||||
await this.tx.customer.update({
|
||||
where: { id: customerId },
|
||||
@@ -326,7 +295,7 @@ function parseOptionalInt(value: string): number | null {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
return Number.isInteger(parsed) && parsed >= -2147483648 && parsed <= 2147483647 ? parsed : null;
|
||||
}
|
||||
|
||||
function parseOptionalCdrDate(value: string): Date | null {
|
||||
@@ -361,6 +330,11 @@ function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
|
||||
source_ip: event.source_ip,
|
||||
caller: event.caller,
|
||||
callee: event.callee,
|
||||
callee_city_code: event.callee_city_code,
|
||||
callee_city_name: event.callee_city_name,
|
||||
callee_province_name: event.callee_province_name,
|
||||
callee_operator: event.callee_operator,
|
||||
callee_number_type: event.callee_number_type,
|
||||
customer_id: event.customer_id,
|
||||
customer_gateway_id: event.customer_gateway_id,
|
||||
customer_gateway_policy_id: event.customer_gateway_policy_id,
|
||||
@@ -378,3 +352,11 @@ function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
|
||||
created_at: event.created_at
|
||||
};
|
||||
}
|
||||
|
||||
function numberCarrier(value: string): 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN' {
|
||||
return value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' ? value : 'UNKNOWN';
|
||||
}
|
||||
|
||||
function phoneNumberType(value: string): 'MOBILE' | 'LANDLINE' | 'INTERNATIONAL' | 'UNKNOWN' {
|
||||
return value === 'MOBILE' || value === 'LANDLINE' || value === 'INTERNATIONAL' ? value : 'UNKNOWN';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Prisma, type PrismaClient } from '@prisma/client';
|
||||
import { CONFIG_ACTIVE_VERSION_KEY } from '@lisglosips/redis';
|
||||
import { publishPendingConfig } from './publisher.js';
|
||||
|
||||
class MemoryRedisMulti {
|
||||
constructor(private readonly writes: Array<{ command: string; key: string; value?: string }>) {}
|
||||
|
||||
set(key: string, value: string) {
|
||||
this.writes.push({ command: 'set', key, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
rpush(key: string, value: string) {
|
||||
this.writes.push({ command: 'rpush', key, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
sadd(key: string, value: string) {
|
||||
this.writes.push({ command: 'sadd', key, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
async exec() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryRedis {
|
||||
writes: Array<{ command: string; key: string; value?: string }> = [];
|
||||
|
||||
async get(key: string) {
|
||||
return key === CONFIG_ACTIVE_VERSION_KEY ? null : null;
|
||||
}
|
||||
|
||||
multi() {
|
||||
return new MemoryRedisMulti(this.writes);
|
||||
}
|
||||
}
|
||||
|
||||
function prismaFixture(): PrismaClient {
|
||||
const now = new Date('2026-06-24T03:20:00.000Z');
|
||||
return {
|
||||
outboxEvent: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 'out_number_library',
|
||||
aggregateType: 'number_library_config',
|
||||
status: 'PENDING',
|
||||
availableAt: now,
|
||||
createdAt: now
|
||||
}
|
||||
],
|
||||
updateMany: async () => ({ count: 1 })
|
||||
},
|
||||
$transaction: async (operations: Array<Promise<unknown>>) => Promise.all(operations),
|
||||
customer: {
|
||||
findMany: async () => [
|
||||
{ id: 'cus_1', status: 'ENABLED', balance: new Prisma.Decimal('10'), creditLimit: new Prisma.Decimal('0'), minBalance: new Prisma.Decimal('0') }
|
||||
]
|
||||
},
|
||||
customerGateway: {
|
||||
findMany: async () => [
|
||||
{ id: 'cgw_1', customerId: 'cus_1', authMode: 'IP', sourceIp: '100.93.185.30', sipUsername: null, sipDomain: null, sipHa1: null, status: 'ENABLED' }
|
||||
]
|
||||
},
|
||||
customerGatewayPolicy: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 'cgp_1',
|
||||
customerId: 'cus_1',
|
||||
gatewayId: 'cgw_1',
|
||||
lineGroupId: 'lg_1',
|
||||
priority: 1,
|
||||
callerMode: 'ANY',
|
||||
callerValue: null,
|
||||
calleeMode: 'ANY',
|
||||
calleeValue: null,
|
||||
status: 'ENABLED'
|
||||
}
|
||||
]
|
||||
},
|
||||
vendorGateway: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 'vgw_1',
|
||||
vendorId: 'ven_1',
|
||||
authMode: 'IP',
|
||||
host: '100.93.185.30',
|
||||
port: 50620,
|
||||
transport: 'udp',
|
||||
sipUsername: null,
|
||||
sipHa1: null,
|
||||
cpsLimit: 10,
|
||||
concurrencyLimit: 30,
|
||||
billingCycleSec: 6,
|
||||
cycleRate: new Prisma.Decimal('0.012'),
|
||||
status: 'ENABLED',
|
||||
forbiddenPeriods: [],
|
||||
codecs: [],
|
||||
prefixRules: [],
|
||||
blockedRegions: [{ regionScope: 'CITY', provinceCode: '340000', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
landingLineGroup: {
|
||||
findMany: async () => [
|
||||
{ id: 'lg_1', name: '默认线路组', status: 'ENABLED', items: [{ id: 'lgi_1', vendorGatewayId: 'vgw_1', priority: 1, weight: 1, concurrencyCap: 30, status: 'ENABLED' }] }
|
||||
]
|
||||
},
|
||||
geoCity: {
|
||||
findMany: async () => [
|
||||
{ code: '340100', provinceCode: '340000', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市', cityLevel: 'PREFECTURE', status: 'ENABLED' }
|
||||
]
|
||||
},
|
||||
phoneNumberSegment: {
|
||||
findMany: async () => [
|
||||
{ segment7: '1380013', cityCode: '340100', provinceName: '安徽省', cityName: '合肥市', carrier: 'MOBILE', city: { provinceCode: '340000' } }
|
||||
]
|
||||
},
|
||||
phoneAreaCode: {
|
||||
findMany: async () => [
|
||||
{ areaCode: '0551', cityCode: '340100', provinceName: '安徽省', cityName: '合肥市', city: { provinceCode: '340000' } }
|
||||
]
|
||||
},
|
||||
carrierPrefixRule: {
|
||||
findMany: async () => [
|
||||
{ prefix: '138', carrier: 'MOBILE', priority: 100 }
|
||||
]
|
||||
}
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe('S42 config publisher number library snapshot', () => {
|
||||
it('publishes number library lookup keys and gateway blocked-region sets', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const now = new Date('2026-06-24T03:20:00.000Z');
|
||||
|
||||
const result = await publishPendingConfig(prismaFixture(), redis as never, now);
|
||||
const version = `${now.getTime()}`;
|
||||
|
||||
expect(result.published).toBe(true);
|
||||
expect(result.manifest).toMatchObject({
|
||||
cityCount: 1,
|
||||
phoneSegmentCount: 1,
|
||||
areaCodeCount: 1,
|
||||
carrierPrefixRuleCount: 1,
|
||||
blockedRegionCount: 1
|
||||
});
|
||||
expect(redis.writes).toEqual(
|
||||
expect.arrayContaining([
|
||||
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` }),
|
||||
expect.objectContaining({ command: 'sadd', key: `cfg:v:${version}:vendor_gateway:vgw_1:blocked_city_codes`, value: '340100' })
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,11 @@ export interface ConfigManifest {
|
||||
policyCount: number;
|
||||
vendorGatewayCount: number;
|
||||
lineGroupCount: number;
|
||||
cityCount: number;
|
||||
phoneSegmentCount: number;
|
||||
areaCodeCount: number;
|
||||
carrierPrefixRuleCount: number;
|
||||
blockedRegionCount: number;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
@@ -74,6 +79,13 @@ type ConfigSnapshot = {
|
||||
forbiddenPeriods: Array<{ weekdayMask: number; startTime: string; endTime: string }>;
|
||||
codecs: Array<{ codec: string; priority: number }>;
|
||||
prefixRules: Array<{ direction: string; matchPrefix: string; replacePrefix: string; priority: number }>;
|
||||
blockedRegions: Array<{
|
||||
regionScope: string;
|
||||
provinceCode: string | null;
|
||||
provinceName: string | null;
|
||||
cityCode: string | null;
|
||||
cityName: string | null;
|
||||
}>;
|
||||
}>;
|
||||
lineGroups: Array<{
|
||||
id: string;
|
||||
@@ -88,12 +100,43 @@ type ConfigSnapshot = {
|
||||
status: string;
|
||||
}>;
|
||||
}>;
|
||||
cities: Array<{
|
||||
code: string;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
cityLevel: string;
|
||||
status: string;
|
||||
}>;
|
||||
phoneSegments: Array<{
|
||||
segment7: string;
|
||||
cityCode: string;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityName: string;
|
||||
carrier: string;
|
||||
numberType: 'MOBILE';
|
||||
}>;
|
||||
areaCodes: Array<{
|
||||
areaCode: string;
|
||||
cityCode: string;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityName: string;
|
||||
numberType: 'LANDLINE';
|
||||
}>;
|
||||
carrierPrefixRules: Array<{
|
||||
prefix: string;
|
||||
carrier: string;
|
||||
priority: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
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'] },
|
||||
aggregateType: { in: ['customer_gateway_config', 'vendor_gateway_config', 'line_group_config', 'number_library_config'] },
|
||||
status: 'PENDING',
|
||||
availableAt: { lte: now }
|
||||
},
|
||||
@@ -174,7 +217,7 @@ export async function rollbackActiveConfig(redis: RedisClient): Promise<{ rolled
|
||||
}
|
||||
|
||||
async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
const [customers, gateways, policies, vendorGateways, lineGroups] = await prisma.$transaction([
|
||||
const [customers, gateways, policies, vendorGateways, lineGroups, cities, phoneSegments, areaCodes, carrierPrefixRules] = await prisma.$transaction([
|
||||
prisma.customer.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ id: 'asc' }]
|
||||
@@ -193,7 +236,8 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
include: {
|
||||
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
|
||||
codecs: { orderBy: [{ priority: 'asc' }] },
|
||||
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] }
|
||||
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] },
|
||||
blockedRegions: { orderBy: [{ regionScope: 'asc' }, { provinceCode: 'asc' }, { cityCode: 'asc' }] }
|
||||
}
|
||||
}),
|
||||
prisma.landingLineGroup.findMany({
|
||||
@@ -202,6 +246,24 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
include: {
|
||||
items: { orderBy: [{ priority: 'asc' }] }
|
||||
}
|
||||
}),
|
||||
prisma.geoCity.findMany({
|
||||
where: { deletedAt: null, status: 'ENABLED' },
|
||||
orderBy: [{ provinceCode: 'asc' }, { cityCode: 'asc' }]
|
||||
}),
|
||||
prisma.phoneNumberSegment.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ segment7: 'asc' }],
|
||||
include: { city: { select: { provinceCode: true } } }
|
||||
}),
|
||||
prisma.phoneAreaCode.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ areaCode: 'asc' }],
|
||||
include: { city: { select: { provinceCode: true } } }
|
||||
}),
|
||||
prisma.carrierPrefixRule.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ prefix: 'desc' }, { priority: 'asc' }]
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -263,6 +325,13 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
matchPrefix: rule.matchPrefix,
|
||||
replacePrefix: rule.replacePrefix,
|
||||
priority: rule.priority
|
||||
})),
|
||||
blockedRegions: gateway.blockedRegions.map((region) => ({
|
||||
regionScope: region.regionScope,
|
||||
provinceCode: region.provinceCode,
|
||||
provinceName: region.provinceName,
|
||||
cityCode: region.cityCode,
|
||||
cityName: region.cityName
|
||||
}))
|
||||
})),
|
||||
lineGroups: lineGroups.map((group) => ({
|
||||
@@ -277,6 +346,37 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
concurrencyCap: item.concurrencyCap,
|
||||
status: item.status
|
||||
}))
|
||||
})),
|
||||
cities: cities.map((city) => ({
|
||||
code: city.code,
|
||||
provinceCode: city.provinceCode,
|
||||
provinceName: city.provinceName,
|
||||
cityCode: city.cityCode,
|
||||
cityName: city.cityName,
|
||||
cityLevel: city.cityLevel,
|
||||
status: city.status
|
||||
})),
|
||||
phoneSegments: phoneSegments.map((segment) => ({
|
||||
segment7: segment.segment7,
|
||||
cityCode: segment.cityCode,
|
||||
provinceCode: segment.city.provinceCode,
|
||||
provinceName: segment.provinceName,
|
||||
cityName: segment.cityName,
|
||||
carrier: segment.carrier,
|
||||
numberType: 'MOBILE'
|
||||
})),
|
||||
areaCodes: areaCodes.map((areaCode) => ({
|
||||
areaCode: areaCode.areaCode,
|
||||
cityCode: areaCode.cityCode,
|
||||
provinceCode: areaCode.city.provinceCode,
|
||||
provinceName: areaCode.provinceName,
|
||||
cityName: areaCode.cityName,
|
||||
numberType: 'LANDLINE'
|
||||
})),
|
||||
carrierPrefixRules: carrierPrefixRules.map((rule) => ({
|
||||
prefix: rule.prefix,
|
||||
carrier: rule.carrier,
|
||||
priority: rule.priority
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -298,6 +398,11 @@ async function writeSnapshot(
|
||||
policyCount: snapshot.policies.length,
|
||||
vendorGatewayCount: snapshot.vendorGateways.length,
|
||||
lineGroupCount: snapshot.lineGroups.length,
|
||||
cityCount: snapshot.cities.length,
|
||||
phoneSegmentCount: snapshot.phoneSegments.length,
|
||||
areaCodeCount: snapshot.areaCodes.length,
|
||||
carrierPrefixRuleCount: snapshot.carrierPrefixRules.length,
|
||||
blockedRegionCount: snapshot.vendorGateways.reduce((sum, gateway) => sum + gateway.blockedRegions.length, 0),
|
||||
checksum
|
||||
};
|
||||
|
||||
@@ -321,6 +426,14 @@ async function writeSnapshot(
|
||||
}
|
||||
for (const gateway of snapshot.vendorGateways) {
|
||||
multi.set(`${prefix}:vendor_gateway:${gateway.id}`, JSON.stringify(gateway));
|
||||
for (const region of gateway.blockedRegions) {
|
||||
if (region.regionScope === 'CITY' && region.cityCode) {
|
||||
multi.sadd(`${prefix}:vendor_gateway:${gateway.id}:blocked_city_codes`, region.cityCode);
|
||||
}
|
||||
if (region.regionScope === 'PROVINCE' && region.provinceCode) {
|
||||
multi.sadd(`${prefix}:vendor_gateway:${gateway.id}:blocked_province_codes`, region.provinceCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const lineGroup of snapshot.lineGroups) {
|
||||
multi.set(`${prefix}:line_group:${lineGroup.id}`, JSON.stringify(lineGroup));
|
||||
@@ -328,6 +441,18 @@ async function writeSnapshot(
|
||||
multi.rpush(`${prefix}:line_group:${lineGroup.id}:items`, JSON.stringify(item));
|
||||
}
|
||||
}
|
||||
for (const city of snapshot.cities) {
|
||||
multi.set(`${prefix}:geo_city:${city.cityCode}`, JSON.stringify(city));
|
||||
}
|
||||
for (const segment of snapshot.phoneSegments) {
|
||||
multi.set(`${prefix}:phone_segment:${segment.segment7}`, JSON.stringify(segment));
|
||||
}
|
||||
for (const areaCode of snapshot.areaCodes) {
|
||||
multi.set(`${prefix}:area_code:${areaCode.areaCode}`, JSON.stringify(areaCode));
|
||||
}
|
||||
for (const rule of snapshot.carrierPrefixRules) {
|
||||
multi.set(`${prefix}:carrier_prefix:${rule.prefix}`, JSON.stringify(rule));
|
||||
}
|
||||
|
||||
if (previousVersion) {
|
||||
multi.set(CONFIG_PREVIOUS_VERSION_KEY, previousVersion);
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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 caller = ARGV[2] or ''
|
||||
local callee = ARGV[3] or ''
|
||||
|
||||
local prefix = 'cfg:v:' .. active
|
||||
|
||||
local function field(json, name)
|
||||
return string.match(json, '"' .. name .. '":"([^"]*)"')
|
||||
end
|
||||
|
||||
local function normalize_callee(value)
|
||||
local digits = string.gsub(value or '', '[^0-9]', '')
|
||||
if string.sub(digits, 1, 4) == '0086' then
|
||||
digits = string.sub(digits, 5)
|
||||
elseif string.sub(digits, 1, 2) == '86' and string.len(digits) == 13 then
|
||||
digits = string.sub(digits, 3)
|
||||
end
|
||||
return digits
|
||||
end
|
||||
|
||||
local function unknown_number(callee_digits)
|
||||
return {
|
||||
city_code = 'UNKNOWN',
|
||||
city_name = 'UNKNOWN',
|
||||
province_code = 'UNKNOWN',
|
||||
province_name = 'UNKNOWN',
|
||||
carrier = 'UNKNOWN',
|
||||
number_type = 'UNKNOWN',
|
||||
normalized = callee_digits or ''
|
||||
}
|
||||
end
|
||||
|
||||
local function with_geo_city(info)
|
||||
if info.city_code == 'UNKNOWN' or info.province_code ~= 'UNKNOWN' then
|
||||
return info
|
||||
end
|
||||
local city_json = redis.call('GET', prefix .. ':geo_city:' .. info.city_code)
|
||||
if city_json then
|
||||
info.province_code = field(city_json, 'provinceCode') or info.province_code
|
||||
info.province_name = field(city_json, 'provinceName') or info.province_name
|
||||
info.city_name = field(city_json, 'cityName') or info.city_name
|
||||
end
|
||||
return info
|
||||
end
|
||||
|
||||
local function resolve_carrier(digits, fallback)
|
||||
if string.len(digits) >= 4 then
|
||||
local prefix4_json = redis.call('GET', prefix .. ':carrier_prefix:' .. string.sub(digits, 1, 4))
|
||||
if prefix4_json then
|
||||
return field(prefix4_json, 'carrier') or fallback
|
||||
end
|
||||
end
|
||||
if string.len(digits) >= 3 then
|
||||
local prefix3_json = redis.call('GET', prefix .. ':carrier_prefix:' .. string.sub(digits, 1, 3))
|
||||
if prefix3_json then
|
||||
return field(prefix3_json, 'carrier') or fallback
|
||||
end
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
|
||||
local function resolve_number(value)
|
||||
local digits = normalize_callee(value)
|
||||
local info = unknown_number(digits)
|
||||
|
||||
if string.match(digits, '^1%d%d%d%d%d%d%d%d%d%d$') then
|
||||
info.number_type = 'MOBILE'
|
||||
local segment_json = redis.call('GET', prefix .. ':phone_segment:' .. string.sub(digits, 1, 7))
|
||||
if segment_json then
|
||||
info.city_code = field(segment_json, 'cityCode') or info.city_code
|
||||
info.city_name = field(segment_json, 'cityName') or info.city_name
|
||||
info.province_code = field(segment_json, 'provinceCode') or info.province_code
|
||||
info.province_name = field(segment_json, 'provinceName') or info.province_name
|
||||
info.carrier = field(segment_json, 'carrier') or info.carrier
|
||||
end
|
||||
info.carrier = resolve_carrier(digits, info.carrier)
|
||||
return with_geo_city(info)
|
||||
end
|
||||
|
||||
if string.sub(digits, 1, 1) == '0' and string.len(digits) >= 3 then
|
||||
info.number_type = 'LANDLINE'
|
||||
local area_json = nil
|
||||
if string.len(digits) >= 4 then
|
||||
area_json = redis.call('GET', prefix .. ':area_code:' .. string.sub(digits, 1, 4))
|
||||
end
|
||||
if not area_json and string.len(digits) >= 3 then
|
||||
area_json = redis.call('GET', prefix .. ':area_code:' .. string.sub(digits, 1, 3))
|
||||
end
|
||||
if area_json then
|
||||
info.city_code = field(area_json, 'cityCode') or info.city_code
|
||||
info.city_name = field(area_json, 'cityName') or info.city_name
|
||||
info.province_code = field(area_json, 'provinceCode') or info.province_code
|
||||
info.province_name = field(area_json, 'provinceName') or info.province_name
|
||||
end
|
||||
return with_geo_city(info)
|
||||
end
|
||||
|
||||
info.carrier = resolve_carrier(digits, info.carrier)
|
||||
return info
|
||||
end
|
||||
|
||||
local number_info = resolve_number(callee)
|
||||
|
||||
local function result(decision, reason, gateway_id, version, line_group_id, policy_id, customer_id, vendor_id, vendor_gateway_id, host, port)
|
||||
return {
|
||||
decision,
|
||||
reason,
|
||||
gateway_id,
|
||||
version,
|
||||
line_group_id,
|
||||
policy_id,
|
||||
customer_id,
|
||||
vendor_id,
|
||||
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
|
||||
}
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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)
|
||||
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 province_blocked == 1 then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function first_vendor_route(line_group_id)
|
||||
local line_group_json = redis.call('GET', prefix .. ':line_group:' .. line_group_id)
|
||||
if not line_group_json then
|
||||
return nil, 'LINE_GROUP_MISSING'
|
||||
end
|
||||
if not string.find(line_group_json, '"status":"ENABLED"', 1, true) then
|
||||
return nil, 'LINE_GROUP_DISABLED'
|
||||
end
|
||||
|
||||
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":"([^"]+)"')
|
||||
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'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if skipped_region then
|
||||
return nil, 'NO_VENDOR_ROUTE_REGION_BLOCKED'
|
||||
end
|
||||
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 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)
|
||||
|
||||
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])
|
||||
end
|
||||
return result('reject', route_reason, gateway_id, active, line_group_id, policy_id, customer_id, 'none', 'none', 'none', 'none')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return result('reject', 'NO_POLICY', gateway_id, active, 'none', 'none', 'no_policy_match', 'none', 'none', 'none', 'none')
|
||||
@@ -18,11 +18,16 @@ loadmodule "signaling.so"
|
||||
loadmodule "sl.so"
|
||||
loadmodule "tm.so"
|
||||
modparam("tm", "fr_timeout", 5)
|
||||
modparam("tm", "fr_inv_timeout", 30)
|
||||
modparam("tm", "fr_inv_timeout", 95)
|
||||
|
||||
loadmodule "rr.so"
|
||||
modparam("rr", "append_fromtag", 1)
|
||||
|
||||
loadmodule "dialog.so"
|
||||
modparam("dialog", "dlg_match_mode", 1)
|
||||
modparam("dialog", "default_timeout", 7200)
|
||||
modparam("dialog", "delete_delay", 10)
|
||||
|
||||
loadmodule "maxfwd.so"
|
||||
loadmodule "sipmsgops.so"
|
||||
loadmodule "textops.so"
|
||||
@@ -167,10 +172,21 @@ route[S28_INVITE] {
|
||||
$var(s28_line_group_id) = "none";
|
||||
$var(s28_policy_id) = "none";
|
||||
$var(s28_customer_id) = "none";
|
||||
$var(s28_vendor_id) = "none";
|
||||
$var(s28_vendor_gateway_id) = "none";
|
||||
$var(s28_vendor_host) = "none";
|
||||
$var(s28_vendor_port) = "5060";
|
||||
$var(s28_callee_city_code) = "UNKNOWN";
|
||||
$var(s28_callee_city_name) = "UNKNOWN";
|
||||
$var(s28_callee_province_name) = "UNKNOWN";
|
||||
$var(s28_callee_operator) = "UNKNOWN";
|
||||
$var(s28_callee_number_type) = "UNKNOWN";
|
||||
$var(s28_callee_province_code) = "UNKNOWN";
|
||||
$var(s28_callee_normalized) = "";
|
||||
$var(s28_reply_code) = 503;
|
||||
$var(s28_reply_text) = "Routing Not Ready";
|
||||
|
||||
if (!cache_raw_query("redis:s20", "EVALSHA cfdc02cbe5528d37fba617947c09e3380770c918 1 cfg:active_version $si $fU $rU $ci", "$avp(s28_hotpath)")) {
|
||||
if (!cache_raw_query("redis:s20", "EVALSHA 229c06326b97d3d64b0b02deedec77a764a61045 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;
|
||||
@@ -187,13 +203,24 @@ route[S28_INVITE] {
|
||||
$var(s28_line_group_id) = $(avp(s28_hotpath)[4]);
|
||||
$var(s28_policy_id) = $(avp(s28_hotpath)[5]);
|
||||
$var(s28_customer_id) = $(avp(s28_hotpath)[6]);
|
||||
$var(s28_vendor_id) = $(avp(s28_hotpath)[7]);
|
||||
$var(s28_vendor_gateway_id) = $(avp(s28_hotpath)[8]);
|
||||
$var(s28_vendor_host) = $(avp(s28_hotpath)[9]);
|
||||
$var(s28_vendor_port) = $(avp(s28_hotpath)[10]);
|
||||
$var(s28_callee_city_code) = $(avp(s28_hotpath)[11]);
|
||||
$var(s28_callee_city_name) = $(avp(s28_hotpath)[12]);
|
||||
$var(s28_callee_province_name) = $(avp(s28_hotpath)[13]);
|
||||
$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]);
|
||||
|
||||
if ($var(s28_decision) != "allow") {
|
||||
update_stat("s28_hotpath_reject_total", 1);
|
||||
if ($var(s28_reason) == "CONFIG_MISSING") {
|
||||
$var(s28_reply_code) = 503;
|
||||
$var(s28_reply_text) = "Config Missing";
|
||||
} else if ($var(s28_reason) == "NO_POLICY") {
|
||||
} 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 {
|
||||
@@ -206,7 +233,30 @@ route[S28_INVITE] {
|
||||
}
|
||||
|
||||
update_stat("s28_hotpath_allow_total", 1);
|
||||
xlog("L_INFO", "S28 hotpath allow source=$si callid=$ci customer=$var(s28_customer_id) gateway=$var(s28_gateway_id) policy=$var(s28_policy_id) line_group=$var(s28_line_group_id) version=$var(s28_config_version)\n");
|
||||
xlog("L_INFO", "S28 hotpath allow source=$si callid=$ci customer=$var(s28_customer_id) gateway=$var(s28_gateway_id) policy=$var(s28_policy_id) line_group=$var(s28_line_group_id) vendor=$var(s28_vendor_id) vendor_gateway=$var(s28_vendor_gateway_id) dst=$var(s28_vendor_host):$var(s28_vendor_port) version=$var(s28_config_version)\n");
|
||||
|
||||
if (!create_dialog()) {
|
||||
xlog("L_ERR", "S28 create_dialog failed callid=$ci\n");
|
||||
$var(s28_reason) = "DIALOG_CREATE_FAILED";
|
||||
$var(s28_reply_code) = 500;
|
||||
$var(s28_reply_text) = "Dialog Error";
|
||||
route(S28_CDR_FAILURE);
|
||||
send_reply($var(s28_reply_code), $var(s28_reply_text));
|
||||
exit;
|
||||
}
|
||||
$dlg_val(s28_customer_id) = $var(s28_customer_id);
|
||||
$dlg_val(s28_gateway_id) = $var(s28_gateway_id);
|
||||
$dlg_val(s28_policy_id) = $var(s28_policy_id);
|
||||
$dlg_val(s28_line_group_id) = $var(s28_line_group_id);
|
||||
$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_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);
|
||||
$dlg_val(s28_callee_operator) = $var(s28_callee_operator);
|
||||
$dlg_val(s28_callee_number_type) = $var(s28_callee_number_type);
|
||||
|
||||
if (has_body("application/sdp")) {
|
||||
if (!rtpengine_offer("replace-origin replace-session-connection record-call=on")) {
|
||||
@@ -221,8 +271,8 @@ route[S28_INVITE] {
|
||||
}
|
||||
|
||||
record_route();
|
||||
$du = "sip:100.93.185.30:50620";
|
||||
$ru = "sip:" + $rU + "@100.93.185.30:50620";
|
||||
$du = "sip:" + $var(s28_vendor_host) + ":" + $var(s28_vendor_port);
|
||||
$ru = "sip:" + $rU + "@" + $var(s28_vendor_host) + ":" + $var(s28_vendor_port);
|
||||
t_on_reply("S28_REPLY");
|
||||
|
||||
if (!t_relay()) {
|
||||
@@ -247,7 +297,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 $rU customer_id cus_s28_t customer_gateway_id cgw_s28_t_ip customer_gateway_policy_id cgp_s28_t_default vendor_id ven_s28_t vendor_gateway_id vgw_s28_t_uas line_group_id llg_s28_t started_at $Ts answered_at $Ts ended_at $Ts duration_sec 6 sip_code 200 hangup_reason NORMAL_CLEARING 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 $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)")) {
|
||||
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 {
|
||||
@@ -259,7 +309,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 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 $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)")) {
|
||||
update_stat("s28_cdr_xadd_total", 1);
|
||||
} else {
|
||||
update_stat("s28_cdr_xadd_error_total", 1);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
MI_URL=${LISGLOSIPS_MI_URL:-http://127.0.0.1:8888/mi}
|
||||
ORIGINAL=${SSH_ORIGINAL_COMMAND:-}
|
||||
|
||||
if [ "$#" -eq 0 ] && [ -n "$ORIGINAL" ]; then
|
||||
set -- $ORIGINAL
|
||||
fi
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
token=${1:-}
|
||||
token=${token#\"}
|
||||
token=${token%\"}
|
||||
token=${token#\'}
|
||||
token=${token%\'}
|
||||
|
||||
case "$token" in
|
||||
/usr/local/sbin/lisglosips-call-control|lisglosips-call-control|'')
|
||||
shift
|
||||
;;
|
||||
dlg_list|dlg_end_dlg)
|
||||
set -- "$token" "${2:-}"
|
||||
break
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
method=${1:-}
|
||||
case "$method" in
|
||||
dlg_list)
|
||||
payload='{"jsonrpc":"2.0","method":"dlg_list","params":[],"id":1}'
|
||||
;;
|
||||
dlg_end_dlg)
|
||||
dialog_id=${2:-}
|
||||
dialog_id=${dialog_id#\"}
|
||||
dialog_id=${dialog_id%\"}
|
||||
dialog_id=${dialog_id#\'}
|
||||
dialog_id=${dialog_id%\'}
|
||||
if ! printf '%s' "$dialog_id" | grep -Eq '^[A-Za-z0-9@._:%+=-]{1,220}$'; then
|
||||
printf '{"jsonrpc":"2.0","error":{"code":-32602,"message":"invalid dialog id"},"id":1}\n'
|
||||
exit 0
|
||||
fi
|
||||
payload='{"jsonrpc":"2.0","method":"dlg_end_dlg","params":["'"$dialog_id"'"],"id":1}'
|
||||
;;
|
||||
*)
|
||||
printf '{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"},"id":1}\n'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
exec curl -fsS -X POST "$MI_URL" -H 'Content-Type: application/json' --data-binary "$payload"
|
||||
@@ -3,5 +3,4 @@ add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "same-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'" always;
|
||||
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'" always;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
import string
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
CRLF = "\r\n"
|
||||
STOP = threading.Event()
|
||||
PRINT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
with PRINT_LOCK:
|
||||
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True)
|
||||
|
||||
|
||||
def token(length: int = 8) -> str:
|
||||
alphabet = string.ascii_lowercase + string.digits
|
||||
return "".join(random.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def header(message: str, name: str) -> str:
|
||||
match = re.search(rf"^{re.escape(name)}\s*:\s*(.+)$", message, re.I | re.M)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
|
||||
def status(message: str) -> str:
|
||||
return message.splitlines()[0] if message else "NO RESPONSE"
|
||||
|
||||
|
||||
def call_id(index: int) -> str:
|
||||
return f"s40-{index:02d}-{int(time.time() * 1000)}-{token()}@lisglosips-t"
|
||||
|
||||
|
||||
def sdp(host: str, port: int) -> str:
|
||||
return (
|
||||
"v=0\r\n"
|
||||
f"o=lisglosips-s40 0 0 IN IP4 {host}\r\n"
|
||||
"s=lisglosips-s40\r\n"
|
||||
f"c=IN IP4 {host}\r\n"
|
||||
"t=0 0\r\n"
|
||||
f"m=audio {port} RTP/AVP 0 8 101\r\n"
|
||||
"a=rtpmap:0 PCMU/8000\r\n"
|
||||
"a=rtpmap:8 PCMA/8000\r\n"
|
||||
"a=rtpmap:101 telephone-event/8000\r\n"
|
||||
)
|
||||
|
||||
|
||||
def parse_sdp_media(message: str) -> tuple[str, int] | None:
|
||||
body = message.split("\r\n\r\n", 1)[1] if "\r\n\r\n" in message else ""
|
||||
media_host = ""
|
||||
media_port = 0
|
||||
for raw_line in body.splitlines():
|
||||
line = raw_line.strip()
|
||||
if line.startswith("c=IN IP4 "):
|
||||
media_host = line.split()[-1]
|
||||
elif line.startswith("m=audio "):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
media_port = int(parts[1])
|
||||
if media_host and media_port:
|
||||
return media_host, media_port
|
||||
return None
|
||||
|
||||
|
||||
def send_pcmu_rtp(target: tuple[str, int], duration: float, bind_host: str = "0.0.0.0") -> None:
|
||||
payload = b"\xff" * 160
|
||||
seq = random.randint(0, 65535)
|
||||
timestamp = random.randint(0, 2**32 - 1)
|
||||
ssrc = random.randint(1, 2**32 - 1)
|
||||
deadline = time.time() + duration
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.bind((bind_host, 0))
|
||||
while time.time() < deadline and not STOP.is_set():
|
||||
rtp_header = struct.pack("!BBHII", 0x80, 0x00, seq, timestamp, ssrc)
|
||||
sock.sendto(rtp_header + payload, target)
|
||||
seq = (seq + 1) % 65536
|
||||
timestamp = (timestamp + 160) % 2**32
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def response(code: int, reason: str, request: str, body: str = "", to_tag: str | None = None) -> str:
|
||||
to_header = header(request, "To")
|
||||
if "tag=" not in to_header:
|
||||
to_header = f"{to_header};tag={to_tag or 's40uas' + token(6)}"
|
||||
lines = [
|
||||
f"SIP/2.0 {code} {reason}",
|
||||
f"Via: {header(request, 'Via')}",
|
||||
f"From: {header(request, 'From')}",
|
||||
f"To: {to_header}",
|
||||
f"Call-ID: {header(request, 'Call-ID')}",
|
||||
f"CSeq: {header(request, 'CSeq')}",
|
||||
"Server: lisglosips-s40-uas",
|
||||
]
|
||||
if body:
|
||||
lines.extend(["Content-Type: application/sdp", f"Content-Length: {len(body.encode())}", "", body])
|
||||
else:
|
||||
lines.extend(["Content-Length: 0", "", ""])
|
||||
return CRLF.join(lines)
|
||||
|
||||
|
||||
def extract_callee(request: str) -> str:
|
||||
first = request.splitlines()[0] if request else ""
|
||||
match = re.search(r"sip:([^@;>\s]+)", first)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def scenario_index(callee: str, callee_base: str) -> int:
|
||||
if callee.startswith(callee_base):
|
||||
suffix = callee[len(callee_base):]
|
||||
if suffix.isdigit():
|
||||
return int(suffix)
|
||||
digits = re.sub(r"\D", "", callee)
|
||||
return int(digits[-2:]) if digits else 0
|
||||
|
||||
|
||||
def call_index(call: str) -> int:
|
||||
match = re.search(r"s40-(\d{2})-", call)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UasConfig:
|
||||
host: str
|
||||
port: int
|
||||
media_host: str
|
||||
media_port_base: int
|
||||
answer_count: int
|
||||
answer_ring: float
|
||||
no_answer_ring: float
|
||||
callee_base: str
|
||||
caller_prefix: str
|
||||
|
||||
|
||||
def extract_user_from_header(value: str) -> str:
|
||||
match = re.search(r"sip:([^@;>\s]+)", value)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def handle_invite(sock: socket.socket, request: str, addr: tuple[str, int], config: UasConfig) -> None:
|
||||
call = header(request, "Call-ID")
|
||||
callee = extract_callee(request)
|
||||
caller = extract_user_from_header(header(request, "From"))
|
||||
index = call_index(call) or scenario_index(callee, config.callee_base) or scenario_index(caller, config.caller_prefix)
|
||||
should_answer = 1 <= index <= config.answer_count
|
||||
ring_seconds = config.answer_ring if should_answer else config.no_answer_ring
|
||||
to_tag = f"s40uas{index:02d}{token(4)}"
|
||||
|
||||
sock.sendto(response(100, "Trying", request, to_tag=to_tag).encode(), addr)
|
||||
sock.sendto(response(180, "Ringing", request, to_tag=to_tag).encode(), addr)
|
||||
log(f"uas call_id={call} caller={caller} callee={callee} scenario={'answer' if should_answer else 'no-answer'} ringing={ring_seconds}s")
|
||||
time.sleep(ring_seconds)
|
||||
|
||||
if STOP.is_set():
|
||||
return
|
||||
|
||||
if not should_answer:
|
||||
sock.sendto(response(480, "Temporarily Unavailable", request, to_tag=to_tag).encode(), addr)
|
||||
log(f"uas call_id={call} final=480 Temporarily Unavailable")
|
||||
return
|
||||
|
||||
media_port = config.media_port_base + max(index, 1)
|
||||
body = sdp(config.media_host, media_port)
|
||||
sock.sendto(response(200, "OK", request, body, to_tag=to_tag).encode(), addr)
|
||||
media = parse_sdp_media(request)
|
||||
if media:
|
||||
threading.Thread(target=send_pcmu_rtp, args=(media, 720.0, config.host), daemon=True).start()
|
||||
log(f"uas call_id={call} final=200 OK media_port={media_port}")
|
||||
|
||||
|
||||
def run_uas(config: UasConfig) -> None:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((config.host, config.port))
|
||||
sock.settimeout(1.0)
|
||||
log(f"uas listening {config.host}:{config.port}")
|
||||
while not STOP.is_set():
|
||||
try:
|
||||
data, addr = sock.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
continue
|
||||
request = data.decode(errors="replace")
|
||||
method = request.split(" ", 1)[0]
|
||||
if method == "INVITE":
|
||||
threading.Thread(target=handle_invite, args=(sock, request, addr, config), daemon=True).start()
|
||||
elif method == "BYE":
|
||||
sock.sendto(response(200, "OK", request).encode(), addr)
|
||||
log(f"uas call_id={header(request, 'Call-ID')} bye=200 OK")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InviteConfig:
|
||||
index: int
|
||||
host: str
|
||||
port: int
|
||||
bind_host: str
|
||||
contact_host: str
|
||||
caller: str
|
||||
callee: str
|
||||
to_domain: str
|
||||
media_host: str
|
||||
media_port: int
|
||||
timeout: float
|
||||
hold: float
|
||||
|
||||
|
||||
def build_in_dialog(method: str, config: InviteConfig, cid: str, from_tag: str, to_header: str, cseq: int, route: str, local_port: int) -> str:
|
||||
lines = [
|
||||
f"{method} sip:{config.callee}@{config.to_domain} SIP/2.0",
|
||||
f"Via: SIP/2.0/UDP {config.contact_host}:{local_port};branch=z9hG4bK-{token(10)};rport",
|
||||
"Max-Forwards: 70",
|
||||
f"From: <sip:{config.caller}@s40.customer.local>;tag={from_tag}",
|
||||
f"To: {to_header}",
|
||||
f"Call-ID: {cid}",
|
||||
f"CSeq: {cseq} {method}",
|
||||
f"Contact: <sip:{config.caller}@{config.contact_host}:{local_port}>",
|
||||
]
|
||||
if route:
|
||||
lines.append(f"Route: {route}")
|
||||
lines.extend(["Content-Length: 0", "", ""])
|
||||
return CRLF.join(lines)
|
||||
|
||||
|
||||
def run_invite(config: InviteConfig) -> None:
|
||||
cid = call_id(config.index)
|
||||
from_tag = token()
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.bind((config.bind_host, 0))
|
||||
local_port = sock.getsockname()[1]
|
||||
sock.settimeout(config.timeout)
|
||||
body = sdp(config.media_host, config.media_port)
|
||||
request = CRLF.join(
|
||||
[
|
||||
f"INVITE sip:{config.callee}@{config.to_domain} SIP/2.0",
|
||||
f"Via: SIP/2.0/UDP {config.contact_host}:{local_port};branch=z9hG4bK-{token(10)};rport",
|
||||
"Max-Forwards: 70",
|
||||
f"From: <sip:{config.caller}@s40.customer.local>;tag={from_tag}",
|
||||
f"To: <sip:{config.callee}@{config.to_domain}>",
|
||||
f"Call-ID: {cid}",
|
||||
"CSeq: 1 INVITE",
|
||||
f"Contact: <sip:{config.caller}@{config.contact_host}:{local_port}>",
|
||||
"User-Agent: lisglosips-s40",
|
||||
"Content-Type: application/sdp",
|
||||
f"Content-Length: {len(body.encode())}",
|
||||
"",
|
||||
body,
|
||||
]
|
||||
)
|
||||
log(f"uac index={config.index:02d} call_id={cid} invite callee={config.callee}")
|
||||
sock.sendto(request.encode(), (config.host, config.port))
|
||||
|
||||
final = ""
|
||||
route = ""
|
||||
to_header = ""
|
||||
while not STOP.is_set():
|
||||
try:
|
||||
message = sock.recvfrom(65535)[0].decode(errors="replace")
|
||||
except socket.timeout:
|
||||
break
|
||||
line = status(message)
|
||||
log(f"uac index={config.index:02d} call_id={cid} status={line}")
|
||||
if message.startswith("SIP/2.0 2"):
|
||||
final = message
|
||||
route = header(message, "Record-Route") or f"<sip:{config.host}:{config.port};lr>"
|
||||
to_header = header(message, "To")
|
||||
break
|
||||
if message.startswith(("SIP/2.0 3", "SIP/2.0 4", "SIP/2.0 5", "SIP/2.0 6")):
|
||||
final = message
|
||||
break
|
||||
|
||||
if not final.startswith("SIP/2.0 2"):
|
||||
log(f"uac index={config.index:02d} call_id={cid} final={status(final)}")
|
||||
return
|
||||
|
||||
ack = build_in_dialog("ACK", config, cid, from_tag, to_header, 1, route, local_port)
|
||||
sock.sendto(ack.encode(), (config.host, config.port))
|
||||
media = parse_sdp_media(final)
|
||||
rtp_thread = None
|
||||
if media:
|
||||
rtp_thread = threading.Thread(target=send_pcmu_rtp, args=(media, config.hold, config.contact_host), daemon=True)
|
||||
rtp_thread.start()
|
||||
log(f"uac index={config.index:02d} call_id={cid} connected hold={config.hold}s")
|
||||
deadline = time.time() + config.hold
|
||||
while time.time() < deadline and not STOP.is_set():
|
||||
time.sleep(min(1.0, deadline - time.time()))
|
||||
if rtp_thread:
|
||||
rtp_thread.join(timeout=1.0)
|
||||
bye = build_in_dialog("BYE", config, cid, from_tag, to_header, 2, route, local_port)
|
||||
sock.sendto(bye.encode(), (config.host, config.port))
|
||||
try:
|
||||
bye_response = sock.recvfrom(65535)[0].decode(errors="replace")
|
||||
except socket.timeout:
|
||||
bye_response = ""
|
||||
log(f"uac index={config.index:02d} call_id={cid} bye_status={status(bye_response)}")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
uas_config = UasConfig(
|
||||
host=args.uas_host,
|
||||
port=args.uas_port,
|
||||
media_host=args.media_host,
|
||||
media_port_base=args.uas_media_port_base,
|
||||
answer_count=args.answer_count,
|
||||
answer_ring=args.answer_ring,
|
||||
no_answer_ring=args.no_answer_ring,
|
||||
callee_base=args.callee_base,
|
||||
caller_prefix=args.caller_prefix,
|
||||
)
|
||||
uas_thread = threading.Thread(target=run_uas, args=(uas_config,), daemon=True)
|
||||
uas_thread.start()
|
||||
time.sleep(1.0)
|
||||
|
||||
workers: list[threading.Thread] = []
|
||||
for index in range(1, args.total + 1):
|
||||
callee = args.callee or f"{args.callee_base}{index:02d}"
|
||||
config = InviteConfig(
|
||||
index=index,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
bind_host=args.bind_host,
|
||||
contact_host=args.contact_host,
|
||||
caller=args.caller or f"{args.caller_prefix}{index:02d}",
|
||||
callee=callee,
|
||||
to_domain=args.to_domain,
|
||||
media_host=args.media_host,
|
||||
media_port=args.uac_media_port_base + index,
|
||||
timeout=args.invite_timeout,
|
||||
hold=args.hold,
|
||||
)
|
||||
worker = threading.Thread(target=run_invite, args=(config,), daemon=True)
|
||||
worker.start()
|
||||
workers.append(worker)
|
||||
if index < args.total:
|
||||
time.sleep(args.interval)
|
||||
|
||||
for worker in workers:
|
||||
worker.join()
|
||||
STOP.set()
|
||||
log("scenario finished")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="LisgloSIPS S40 10-way virtual call scenario")
|
||||
parser.add_argument("--host", default="100.90.90.90", help="OpenSIPS A host")
|
||||
parser.add_argument("--port", type=int, default=15060, help="OpenSIPS A SIP port")
|
||||
parser.add_argument("--bind-host", default="0.0.0.0")
|
||||
parser.add_argument("--contact-host", default="100.93.185.30")
|
||||
parser.add_argument("--to-domain", default="100.90.90.90:15060")
|
||||
parser.add_argument("--media-host", default="100.93.185.30")
|
||||
parser.add_argument("--uas-host", default="100.93.185.30")
|
||||
parser.add_argument("--uas-port", type=int, default=50620)
|
||||
parser.add_argument("--total", type=int, default=10)
|
||||
parser.add_argument("--interval", type=float, default=15.0)
|
||||
parser.add_argument("--answer-count", type=int, default=5)
|
||||
parser.add_argument("--answer-ring", type=float, default=45.0)
|
||||
parser.add_argument("--no-answer-ring", type=float, default=70.0)
|
||||
parser.add_argument("--hold", type=float, default=600.0)
|
||||
parser.add_argument("--invite-timeout", type=float, default=90.0)
|
||||
parser.add_argument("--callee-base", default="13800140")
|
||||
parser.add_argument("--callee", default="13800136036", help="Fixed called number. Empty string enables callee-base + index.")
|
||||
parser.add_argument("--caller-prefix", default="s40-100")
|
||||
parser.add_argument("--caller", default="s36-1001", help="Fixed caller. Empty string enables caller-prefix + index.")
|
||||
parser.add_argument("--uac-media-port-base", type=int, default=33100)
|
||||
parser.add_argument("--uas-media-port-base", type=int, default=34100)
|
||||
args = parser.parse_args()
|
||||
if args.total < 1:
|
||||
parser.error("--total must be >= 1")
|
||||
if args.answer_count > args.total:
|
||||
parser.error("--answer-count must be <= --total")
|
||||
try:
|
||||
return run(args)
|
||||
except KeyboardInterrupt:
|
||||
STOP.set()
|
||||
log("interrupted")
|
||||
return 130
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,6 +5,8 @@ export const PASSWORD_ALGO_ARGON2ID = 'argon2id';
|
||||
|
||||
export type PermissionKey =
|
||||
| 'dashboard.view'
|
||||
| 'active_calls.view'
|
||||
| 'active_calls.manage'
|
||||
| 'customers.view'
|
||||
| 'customers.manage'
|
||||
| 'customer_gateways.view'
|
||||
@@ -15,6 +17,8 @@ export type PermissionKey =
|
||||
| 'vendor_gateways.manage'
|
||||
| 'line_groups.view'
|
||||
| 'line_groups.manage'
|
||||
| 'number_library.view'
|
||||
| 'number_library.manage'
|
||||
| 'recharges.view'
|
||||
| 'recharges.manage'
|
||||
| 'cdr.view'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
||||
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');
|
||||
|
||||
describe('Prisma schema contract', () => {
|
||||
it('defines every V2 core business table', () => {
|
||||
@@ -16,11 +17,16 @@ describe('Prisma schema contract', () => {
|
||||
'vendors',
|
||||
'vendor_recharges',
|
||||
'vendor_gateways',
|
||||
'vendor_gateway_blocked_regions',
|
||||
'vendor_gateway_forbidden_periods',
|
||||
'vendor_gateway_codecs',
|
||||
'vendor_gateway_prefix_rules',
|
||||
'landing_line_groups',
|
||||
'landing_line_group_items',
|
||||
'geo_cities',
|
||||
'phone_number_segments',
|
||||
'phone_area_codes',
|
||||
'carrier_prefix_rules',
|
||||
'raw_cdrs',
|
||||
'rated_cdrs',
|
||||
'recordings',
|
||||
@@ -39,7 +45,7 @@ describe('Prisma schema contract', () => {
|
||||
|
||||
for (const table of tables) {
|
||||
expect(schema).toContain(`@@map("${table}")`);
|
||||
expect(`${migration}\n${authMigration}`).toContain(`CREATE TABLE \`${table}\``);
|
||||
expect(`${migration}\n${authMigration}\n${numberLibraryMigration}`).toContain(`CREATE TABLE \`${table}\``);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,6 +58,16 @@ describe('Prisma schema contract', () => {
|
||||
expect(migration).toContain('UNIQUE INDEX `idempotency_keys_key_key`');
|
||||
});
|
||||
|
||||
it('captures number library and CDR callee attribution fields', () => {
|
||||
expect(schema).toContain('model GeoCity');
|
||||
expect(schema).toContain('model PhoneNumberSegment');
|
||||
expect(schema).toContain('model CarrierPrefixRule');
|
||||
expect(numberLibraryMigration).toContain('`segment7` CHAR(7) NOT NULL');
|
||||
expect(numberLibraryMigration).toContain('`callee_city_code` VARCHAR(12) NULL');
|
||||
expect(numberLibraryMigration).toContain('`callee_operator` ENUM');
|
||||
expect(numberLibraryMigration).toContain('CREATE INDEX `raw_cdrs_callee_operator_started_at_idx`');
|
||||
});
|
||||
|
||||
it('models audit columns and outbox publishing state', () => {
|
||||
expect(schema).toContain('createdAt');
|
||||
expect(schema).toContain('updatedAt');
|
||||
|
||||
@@ -142,6 +142,11 @@ function sampleEvent(overrides: Partial<CdrStreamPublishInput> = {}): CdrStreamP
|
||||
sourceIp: '100.93.185.30',
|
||||
caller: 's21-ip-1001',
|
||||
callee: '13800138000',
|
||||
calleeCityCode: '340100',
|
||||
calleeCityName: '合肥市',
|
||||
calleeProvinceName: '安徽省',
|
||||
calleeOperator: 'MOBILE',
|
||||
calleeNumberType: 'MOBILE',
|
||||
sipCode: 503,
|
||||
hangupReason: 'CONFIG_MISSING',
|
||||
configVersion: '0',
|
||||
@@ -162,9 +167,45 @@ 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_city_code).toBe('340100');
|
||||
expect(parsed.callee_city_name).toBe('合肥市');
|
||||
expect(parsed.callee_operator).toBe('MOBILE');
|
||||
expect(parsed.sipCode).toBe(503);
|
||||
});
|
||||
|
||||
it('keeps old stream entries compatible when number snapshot fields are absent', () => {
|
||||
const parsed = parseCdrStreamEvent([
|
||||
'schema_version',
|
||||
'1',
|
||||
'event_id',
|
||||
'evt-old',
|
||||
'idempotency_key',
|
||||
'call-old:1',
|
||||
'call_id',
|
||||
'call-old',
|
||||
'node_id',
|
||||
'a1',
|
||||
'opensips_instance',
|
||||
'opensips-a1',
|
||||
'ingress_a_ip',
|
||||
'100.90.90.90',
|
||||
'rtpengine_node',
|
||||
'a1',
|
||||
'source_ip',
|
||||
'100.93.185.30',
|
||||
'sip_code',
|
||||
'503',
|
||||
'hangup_reason',
|
||||
'CONFIG_MISSING',
|
||||
'created_at',
|
||||
'2026-06-21T07:30:00.000Z'
|
||||
]);
|
||||
|
||||
expect(parsed.callee_city_code).toBe('');
|
||||
expect(parsed.callee_operator).toBe('UNKNOWN');
|
||||
expect(parsed.callee_number_type).toBe('UNKNOWN');
|
||||
});
|
||||
|
||||
it('creates the consumer group idempotently', async () => {
|
||||
const redis = new FakeRedis();
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface CdrStreamEvent {
|
||||
source_ip: string;
|
||||
caller: string;
|
||||
callee: string;
|
||||
callee_city_code: string;
|
||||
callee_city_name: string;
|
||||
callee_province_name: string;
|
||||
callee_operator: string;
|
||||
callee_number_type: string;
|
||||
vendor_id: string;
|
||||
vendor_gateway_id: string;
|
||||
line_group_id: string;
|
||||
@@ -69,6 +74,11 @@ export interface CdrStreamPublishInput {
|
||||
sourceIp: string;
|
||||
caller?: string;
|
||||
callee?: string;
|
||||
calleeCityCode?: string;
|
||||
calleeCityName?: string;
|
||||
calleeProvinceName?: string;
|
||||
calleeOperator?: string;
|
||||
calleeNumberType?: string;
|
||||
vendorId?: string;
|
||||
vendorGatewayId?: string;
|
||||
lineGroupId?: string;
|
||||
@@ -154,6 +164,11 @@ export function buildCdrStreamEvent(input: CdrStreamPublishInput): CdrStreamEven
|
||||
source_ip: input.sourceIp,
|
||||
caller: input.caller ?? '',
|
||||
callee: input.callee ?? '',
|
||||
callee_city_code: input.calleeCityCode ?? '',
|
||||
callee_city_name: input.calleeCityName ?? '',
|
||||
callee_province_name: input.calleeProvinceName ?? '',
|
||||
callee_operator: input.calleeOperator ?? 'UNKNOWN',
|
||||
callee_number_type: input.calleeNumberType ?? 'UNKNOWN',
|
||||
vendor_id: input.vendorId ?? '',
|
||||
vendor_gateway_id: input.vendorGatewayId ?? '',
|
||||
line_group_id: input.lineGroupId ?? '',
|
||||
@@ -220,6 +235,11 @@ export function parseCdrStreamEvent(fields: string[]): ParsedCdrStreamEvent {
|
||||
source_ip: parsed.source_ip,
|
||||
caller: parsed.caller ?? '',
|
||||
callee: parsed.callee ?? '',
|
||||
callee_city_code: parsed.callee_city_code ?? '',
|
||||
callee_city_name: parsed.callee_city_name ?? '',
|
||||
callee_province_name: parsed.callee_province_name ?? '',
|
||||
callee_operator: parsed.callee_operator ?? 'UNKNOWN',
|
||||
callee_number_type: parsed.callee_number_type ?? 'UNKNOWN',
|
||||
vendor_id: parsed.vendor_id ?? '',
|
||||
vendor_gateway_id: parsed.vendor_gateway_id ?? '',
|
||||
line_group_id: parsed.line_group_id ?? '',
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `geo_cities` (
|
||||
`code` VARCHAR(12) NOT NULL,
|
||||
`province_code` VARCHAR(12) NOT NULL,
|
||||
`province_name` VARCHAR(80) NOT NULL,
|
||||
`city_code` VARCHAR(12) NOT NULL,
|
||||
`city_name` VARCHAR(80) NOT NULL,
|
||||
`city_level` VARCHAR(32) NOT NULL,
|
||||
`status` ENUM('ENABLED', 'DISABLED') NOT NULL DEFAULT 'ENABLED',
|
||||
`effective_from` DATETIME(3) NULL,
|
||||
`effective_to` DATETIME(3) NULL,
|
||||
`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 `geo_cities_city_code_key`(`city_code`),
|
||||
INDEX `geo_cities_province_code_idx`(`province_code`),
|
||||
INDEX `geo_cities_province_name_city_name_idx`(`province_name`, `city_name`),
|
||||
INDEX `geo_cities_status_idx`(`status`),
|
||||
INDEX `geo_cities_deleted_at_idx`(`deleted_at`),
|
||||
PRIMARY KEY (`code`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `phone_number_segments` (
|
||||
`segment7` CHAR(7) NOT NULL,
|
||||
`city_code` VARCHAR(12) NOT NULL,
|
||||
`province_name` VARCHAR(80) NOT NULL,
|
||||
`city_name` VARCHAR(80) NOT NULL,
|
||||
`carrier` ENUM('MOBILE', 'UNICOM', 'TELECOM', 'BROADCAST', 'MVNO', 'UNKNOWN') NOT NULL DEFAULT 'UNKNOWN',
|
||||
`source` VARCHAR(120) NULL,
|
||||
`batch_id` VARCHAR(64) NULL,
|
||||
`effective_from` DATETIME(3) NULL,
|
||||
`effective_to` DATETIME(3) NULL,
|
||||
`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 `phone_number_segments_city_code_idx`(`city_code`),
|
||||
INDEX `phone_number_segments_carrier_idx`(`carrier`),
|
||||
INDEX `phone_number_segments_batch_id_idx`(`batch_id`),
|
||||
INDEX `phone_number_segments_deleted_at_idx`(`deleted_at`),
|
||||
PRIMARY KEY (`segment7`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `phone_area_codes` (
|
||||
`area_code` VARCHAR(8) NOT NULL,
|
||||
`city_code` VARCHAR(12) NOT NULL,
|
||||
`province_name` VARCHAR(80) NOT NULL,
|
||||
`city_name` VARCHAR(80) NOT NULL,
|
||||
`source` VARCHAR(120) NULL,
|
||||
`batch_id` VARCHAR(64) NULL,
|
||||
`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 `phone_area_codes_city_code_idx`(`city_code`),
|
||||
INDEX `phone_area_codes_batch_id_idx`(`batch_id`),
|
||||
INDEX `phone_area_codes_deleted_at_idx`(`deleted_at`),
|
||||
PRIMARY KEY (`area_code`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `carrier_prefix_rules` (
|
||||
`prefix` VARCHAR(4) NOT NULL,
|
||||
`carrier` ENUM('MOBILE', 'UNICOM', 'TELECOM', 'BROADCAST', 'MVNO', 'UNKNOWN') NOT NULL,
|
||||
`priority` INTEGER NOT NULL DEFAULT 100,
|
||||
`source` VARCHAR(120) NULL,
|
||||
`batch_id` VARCHAR(64) NULL,
|
||||
`effective_from` DATETIME(3) NULL,
|
||||
`effective_to` DATETIME(3) NULL,
|
||||
`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 `carrier_prefix_rules_carrier_idx`(`carrier`),
|
||||
INDEX `carrier_prefix_rules_priority_idx`(`priority`),
|
||||
INDEX `carrier_prefix_rules_batch_id_idx`(`batch_id`),
|
||||
INDEX `carrier_prefix_rules_deleted_at_idx`(`deleted_at`),
|
||||
PRIMARY KEY (`prefix`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `vendor_gateway_blocked_regions` (
|
||||
`id` VARCHAR(32) NOT NULL,
|
||||
`vendor_gateway_id` VARCHAR(32) NOT NULL,
|
||||
`region_scope` ENUM('PROVINCE', 'CITY') NOT NULL,
|
||||
`province_code` VARCHAR(12) NULL,
|
||||
`province_name` VARCHAR(80) NULL,
|
||||
`city_code` VARCHAR(12) NULL,
|
||||
`city_name` VARCHAR(80) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_by` VARCHAR(32) NULL,
|
||||
|
||||
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`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `raw_cdrs`
|
||||
ADD COLUMN `callee_city_code` VARCHAR(12) NULL,
|
||||
ADD COLUMN `callee_city_name` VARCHAR(80) NULL,
|
||||
ADD COLUMN `callee_province_name` VARCHAR(80) NULL,
|
||||
ADD COLUMN `callee_operator` ENUM('MOBILE', 'UNICOM', 'TELECOM', 'BROADCAST', 'MVNO', 'UNKNOWN') NOT NULL DEFAULT 'UNKNOWN',
|
||||
ADD COLUMN `callee_number_type` ENUM('MOBILE', 'LANDLINE', 'INTERNATIONAL', 'UNKNOWN') NOT NULL DEFAULT 'UNKNOWN';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `raw_cdrs_callee_city_code_started_at_idx` ON `raw_cdrs`(`callee_city_code`, `started_at`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `raw_cdrs_callee_operator_started_at_idx` ON `raw_cdrs`(`callee_operator`, `started_at`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `phone_number_segments` ADD CONSTRAINT `phone_number_segments_city_code_fkey` FOREIGN KEY (`city_code`) REFERENCES `geo_cities`(`city_code`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `phone_area_codes` ADD CONSTRAINT `phone_area_codes_city_code_fkey` FOREIGN KEY (`city_code`) REFERENCES `geo_cities`(`city_code`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `vendor_gateway_blocked_regions` ADD CONSTRAINT `vendor_gateway_blocked_regions_vendor_gateway_id_fkey` FOREIGN KEY (`vendor_gateway_id`) REFERENCES `vendor_gateways`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `vendor_gateway_blocked_regions` ADD CONSTRAINT `vendor_gateway_blocked_regions_city_code_fkey` FOREIGN KEY (`city_code`) REFERENCES `geo_cities`(`city_code`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -52,6 +52,33 @@ enum PrefixRuleDirection {
|
||||
@@map("prefix_rule_direction")
|
||||
}
|
||||
|
||||
enum NumberCarrier {
|
||||
MOBILE
|
||||
UNICOM
|
||||
TELECOM
|
||||
BROADCAST
|
||||
MVNO
|
||||
UNKNOWN
|
||||
|
||||
@@map("number_carrier")
|
||||
}
|
||||
|
||||
enum PhoneNumberType {
|
||||
MOBILE
|
||||
LANDLINE
|
||||
INTERNATIONAL
|
||||
UNKNOWN
|
||||
|
||||
@@map("phone_number_type")
|
||||
}
|
||||
|
||||
enum RegionScope {
|
||||
PROVINCE
|
||||
CITY
|
||||
|
||||
@@map("region_scope")
|
||||
}
|
||||
|
||||
enum CdrRatingStatus {
|
||||
UNRATED
|
||||
RATED
|
||||
@@ -283,6 +310,7 @@ model VendorGateway {
|
||||
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
||||
forbiddenPeriods VendorGatewayForbiddenPeriod[]
|
||||
blockedRegions VendorGatewayBlockedRegion[]
|
||||
codecs VendorGatewayCodec[]
|
||||
prefixRules VendorGatewayPrefixRule[]
|
||||
lineGroupItems LandingLineGroupItem[]
|
||||
@@ -295,6 +323,125 @@ model VendorGateway {
|
||||
@@map("vendor_gateways")
|
||||
}
|
||||
|
||||
model GeoCity {
|
||||
code String @id @db.VarChar(12)
|
||||
provinceCode String @map("province_code") @db.VarChar(12)
|
||||
provinceName String @map("province_name") @db.VarChar(80)
|
||||
cityCode String @unique @map("city_code") @db.VarChar(12)
|
||||
cityName String @map("city_name") @db.VarChar(80)
|
||||
cityLevel String @map("city_level") @db.VarChar(32)
|
||||
status EntityStatus @default(ENABLED)
|
||||
effectiveFrom DateTime? @map("effective_from") @db.DateTime(3)
|
||||
effectiveTo DateTime? @map("effective_to") @db.DateTime(3)
|
||||
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)
|
||||
|
||||
phoneSegments PhoneNumberSegment[]
|
||||
areaCodes PhoneAreaCode[]
|
||||
blockedRegions VendorGatewayBlockedRegion[]
|
||||
|
||||
@@index([provinceCode])
|
||||
@@index([provinceName, cityName])
|
||||
@@index([status])
|
||||
@@index([deletedAt])
|
||||
@@map("geo_cities")
|
||||
}
|
||||
|
||||
model PhoneNumberSegment {
|
||||
segment7 String @id @map("segment7") @db.Char(7)
|
||||
cityCode String @map("city_code") @db.VarChar(12)
|
||||
provinceName String @map("province_name") @db.VarChar(80)
|
||||
cityName String @map("city_name") @db.VarChar(80)
|
||||
carrier NumberCarrier @default(UNKNOWN)
|
||||
source String? @db.VarChar(120)
|
||||
batchId String? @map("batch_id") @db.VarChar(64)
|
||||
effectiveFrom DateTime? @map("effective_from") @db.DateTime(3)
|
||||
effectiveTo DateTime? @map("effective_to") @db.DateTime(3)
|
||||
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)
|
||||
|
||||
city GeoCity @relation(fields: [cityCode], references: [cityCode], onDelete: Restrict)
|
||||
|
||||
@@index([cityCode])
|
||||
@@index([carrier])
|
||||
@@index([batchId])
|
||||
@@index([deletedAt])
|
||||
@@map("phone_number_segments")
|
||||
}
|
||||
|
||||
model PhoneAreaCode {
|
||||
areaCode String @id @map("area_code") @db.VarChar(8)
|
||||
cityCode String @map("city_code") @db.VarChar(12)
|
||||
provinceName String @map("province_name") @db.VarChar(80)
|
||||
cityName String @map("city_name") @db.VarChar(80)
|
||||
source String? @db.VarChar(120)
|
||||
batchId String? @map("batch_id") @db.VarChar(64)
|
||||
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)
|
||||
|
||||
city GeoCity @relation(fields: [cityCode], references: [cityCode], onDelete: Restrict)
|
||||
|
||||
@@index([cityCode])
|
||||
@@index([batchId])
|
||||
@@index([deletedAt])
|
||||
@@map("phone_area_codes")
|
||||
}
|
||||
|
||||
model CarrierPrefixRule {
|
||||
prefix String @id @db.VarChar(4)
|
||||
carrier NumberCarrier
|
||||
priority Int @default(100)
|
||||
source String? @db.VarChar(120)
|
||||
batchId String? @map("batch_id") @db.VarChar(64)
|
||||
effectiveFrom DateTime? @map("effective_from") @db.DateTime(3)
|
||||
effectiveTo DateTime? @map("effective_to") @db.DateTime(3)
|
||||
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)
|
||||
|
||||
@@index([carrier])
|
||||
@@index([priority])
|
||||
@@index([batchId])
|
||||
@@index([deletedAt])
|
||||
@@map("carrier_prefix_rules")
|
||||
}
|
||||
|
||||
model VendorGatewayBlockedRegion {
|
||||
id String @id @db.VarChar(32)
|
||||
vendorGatewayId String @map("vendor_gateway_id") @db.VarChar(32)
|
||||
regionScope RegionScope @map("region_scope")
|
||||
provinceCode String? @map("province_code") @db.VarChar(12)
|
||||
provinceName String? @map("province_name") @db.VarChar(80)
|
||||
cityCode String? @map("city_code") @db.VarChar(12)
|
||||
cityName String? @map("city_name") @db.VarChar(80)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
createdBy String? @map("created_by") @db.VarChar(32)
|
||||
|
||||
vendorGateway VendorGateway @relation(fields: [vendorGatewayId], references: [id], onDelete: Cascade)
|
||||
city GeoCity? @relation(fields: [cityCode], references: [cityCode], onDelete: Restrict)
|
||||
|
||||
@@unique([vendorGatewayId, regionScope, provinceCode, cityCode])
|
||||
@@index([vendorGatewayId])
|
||||
@@index([provinceCode])
|
||||
@@index([cityCode])
|
||||
@@map("vendor_gateway_blocked_regions")
|
||||
}
|
||||
|
||||
model VendorGatewayForbiddenPeriod {
|
||||
id String @id @db.VarChar(32)
|
||||
vendorGatewayId String @map("vendor_gateway_id") @db.VarChar(32)
|
||||
@@ -406,6 +553,11 @@ model RawCdr {
|
||||
sourceIp String? @map("source_ip") @db.VarChar(45)
|
||||
caller String @db.VarChar(64)
|
||||
callee String @db.VarChar(64)
|
||||
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)
|
||||
calleeOperator NumberCarrier @default(UNKNOWN) @map("callee_operator")
|
||||
calleeNumberType PhoneNumberType @default(UNKNOWN) @map("callee_number_type")
|
||||
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)
|
||||
@@ -434,6 +586,8 @@ model RawCdr {
|
||||
@@unique([callId, endedAt])
|
||||
@@index([customerId, startedAt])
|
||||
@@index([vendorId, startedAt])
|
||||
@@index([calleeCityCode, startedAt])
|
||||
@@index([calleeOperator, startedAt])
|
||||
@@index([sipCode])
|
||||
@@index([ratingStatus])
|
||||
@@map("raw_cdrs")
|
||||
|
||||
+12
-1
@@ -4,6 +4,8 @@ const prisma = new PrismaClient();
|
||||
|
||||
const permissions = [
|
||||
['dashboard.view', 'dashboard', 'view', '查看 Dashboard 指标'],
|
||||
['active_calls.view', 'active_calls', 'view', '查看当前通话'],
|
||||
['active_calls.manage', 'active_calls', 'manage', '强制挂断当前通话'],
|
||||
['customers.view', 'customers', 'view', '查看客户'],
|
||||
['customers.manage', 'customers', 'manage', '管理客户'],
|
||||
['customer_gateways.view', 'customer_gateways', 'view', '查看客户网关'],
|
||||
@@ -14,6 +16,8 @@ const permissions = [
|
||||
['vendor_gateways.manage', 'vendor_gateways', 'manage', '管理落地网关'],
|
||||
['line_groups.view', 'line_groups', 'view', '查看落地线路组'],
|
||||
['line_groups.manage', 'line_groups', 'manage', '管理落地线路组'],
|
||||
['number_library.view', 'number_library', 'view', '查看号码库'],
|
||||
['number_library.manage', 'number_library', 'manage', '管理号码库'],
|
||||
['recharges.view', 'recharges', 'view', '查看充值流水'],
|
||||
['recharges.manage', 'recharges', 'manage', '执行客户或供应商充值'],
|
||||
['cdr.view', 'cdr', 'view', '查看话单'],
|
||||
@@ -40,6 +44,7 @@ const roles = [
|
||||
description: '客户、网关、线路和话单运营',
|
||||
permissionIds: [
|
||||
'dashboard.view',
|
||||
'active_calls.view',
|
||||
'customers.view',
|
||||
'customers.manage',
|
||||
'customer_gateways.view',
|
||||
@@ -48,6 +53,8 @@ const roles = [
|
||||
'vendor_gateways.view',
|
||||
'line_groups.view',
|
||||
'line_groups.manage',
|
||||
'number_library.view',
|
||||
'number_library.manage',
|
||||
'cdr.view',
|
||||
'recordings.play'
|
||||
]
|
||||
@@ -75,9 +82,13 @@ const roles = [
|
||||
'vendor_gateways.view',
|
||||
'vendor_gateways.manage',
|
||||
'line_groups.view',
|
||||
'number_library.view',
|
||||
'number_library.manage',
|
||||
'cdr.view',
|
||||
'recordings.play',
|
||||
'audit.view'
|
||||
'audit.view',
|
||||
'active_calls.view',
|
||||
'active_calls.manage'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user