feat: add number library routing and cdr location support
This commit is contained in:
+243
-4
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
- 当前任务:无,S00-S30 本地 V2 闭环已完成
|
- 当前任务:需求修改/二期变更:号码库第 6、7 步本地实现并提交 Git
|
||||||
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环冻结在 `s28-v2-20260621220924`,阿里云迁移前需按 S30 Runbook 重新演练
|
- 总体状态: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-22 10:35 +08:00
|
- 最后更新:2026-06-24 11:41 +08:00
|
||||||
- 当前阻塞:无。遗留风险为 Redis 恢复后 OpenSIPS 热路径可能需要重启 `opensips` 才恢复,已纳入 S30 Runbook,根因级修复需后续单独处理。
|
- 当前阻塞:无。遗留风险为 Redis 恢复后 OpenSIPS 热路径可能需要重启 `opensips` 才恢复,已纳入 S30 Runbook;本次发现并补齐 B `config-publisher.env`,当前 `lisglosips@config-publisher` 已 active。
|
||||||
- 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云
|
- 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云
|
||||||
- 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显
|
- 私密访问资料:`.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、灰度与上线完成
|
### 2026-06-22 10:35 - S30 备份、Runbook、灰度与上线完成
|
||||||
|
|
||||||
- 状态:已完成
|
- 状态:已完成
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# LisgloSIPS V2 项目设计与实施文档
|
# LisgloSIPS V2 项目设计与实施文档
|
||||||
|
|
||||||
> 中文名:聆界SIP管理平台
|
> 中文名:聆界SIP管理平台
|
||||||
> 文档版本:V2.0
|
> 文档版本:V2.2
|
||||||
> 编制日期:2026-06-20
|
> 编制日期:2026-06-20
|
||||||
> 文档状态:实施基线草案
|
> 文档状态:S30 后二期需求变更持续补充
|
||||||
> 依据:服务器架构图、`SOFTSWITCH_PLATFORM_DESIGN_V1.md`、当前 React Web Demo、`OPENSIPS_INSTALL_NOTES.md`
|
> 依据:服务器架构图、`SOFTSWITCH_PLATFORM_DESIGN_V1.md`、当前 React Web Demo、`OPENSIPS_INSTALL_NOTES.md`
|
||||||
|
|
||||||
## 1. 文档目的
|
## 1. 文档目的
|
||||||
@@ -21,12 +21,14 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上
|
|||||||
| 菜单 | V2 实施内容 |
|
| 菜单 | V2 实施内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 概览 Dashboard | 通话、接通率、消费、成本、毛利、注册、节点、异常网关、质检指标 |
|
| 概览 Dashboard | 通话、接通率、消费、成本、毛利、注册、节点、异常网关、质检指标 |
|
||||||
| 客户管理 | 客户新增、编辑、启停、余额、授信、充值、网关数量 |
|
| 客户管理 | 客户新增、编辑、启停、余额、授信、充值、网关数量、受控删除 |
|
||||||
| 客户网关管理 | IP/SIP 注册认证、启停、多个策略、优先级、主被叫匹配、线路组绑定 |
|
| 客户网关管理 | IP/SIP 注册认证、启停、删除、多个策略、优先级、主被叫匹配、线路组绑定 |
|
||||||
| 充值记录 | 客户充值、供应商充值、余额前后值、操作人、备注 |
|
| 充值记录 | 客户充值、供应商充值、余额前后值、操作人、备注;仅记录页面人工充值,不记录话单消费扣费 |
|
||||||
| 供应商管理 | 供应商账户、余额、授信、充值 |
|
| 供应商管理 | 供应商账户、余额、授信、充值、落地网关数量、受控删除 |
|
||||||
| 落地网关管理 | 认证、并发、CPS、禁呼时段、编码、号码转换、计费周期、费率、启停 |
|
| 落地网关管理 | 认证、并发、CPS、禁呼时段、编码、号码转换、计费周期、费率、启停、删除 |
|
||||||
| 落地线路组 | 线路组、组内网关、优先级、并发汇总 |
|
| 落地线路组 | 线路组、组内网关、优先级、并发汇总、使用客户网关数量、受控删除 |
|
||||||
|
| 号码库 | 手机号码库、城市区号、运营商号码段规则;为话单归属地/运营商和落地网关屏蔽地区提供基础数据 |
|
||||||
|
| 当前通话 | OpenSIPS Dialog 实时列表、呼叫方 IP、落地 IP、自动刷新、强制挂断 |
|
||||||
| 话单中心 | 话单查询、挂断原因、费用、录音、信令入口、详情 |
|
| 话单中心 | 话单查询、挂断原因、费用、录音、信令入口、详情 |
|
||||||
| 质检中心 | 抽检规则、录音列表、连续播放、自动播放、问题标注、评分 |
|
| 质检中心 | 抽检规则、录音列表、连续播放、自动播放、问题标注、评分 |
|
||||||
| 用户管理 | 用户新增、编辑、启停、重置密码、角色绑定 |
|
| 用户管理 | 用户新增、编辑、启停、重置密码、角色绑定 |
|
||||||
@@ -35,7 +37,7 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上
|
|||||||
|
|
||||||
### 2.2 延期页面
|
### 2.2 延期页面
|
||||||
|
|
||||||
以下菜单在 Web Demo 中标记为“待设计”,本期不开发其业务页面:
|
以下菜单在 Web Demo 中曾标记为“待设计”,S30 后二期变更要求在导航中隐藏,不向运营用户展示入口:
|
||||||
|
|
||||||
- 费率与计费
|
- 费率与计费
|
||||||
- SIP 运维
|
- SIP 运维
|
||||||
@@ -299,7 +301,8 @@ INVITE
|
|||||||
-> Redis 查询客户、客户网关、状态、余额门槛
|
-> Redis 查询客户、客户网关、状态、余额门槛
|
||||||
-> Redis 查询客户网关策略(主叫条件 AND 被叫条件,按优先级)
|
-> Redis 查询客户网关策略(主叫条件 AND 被叫条件,按优先级)
|
||||||
-> 获得落地线路组
|
-> 获得落地线路组
|
||||||
-> 按组内优先级、启用状态、并发/CPS 选择落地网关
|
-> 识别被叫号码归属地级市和运营商
|
||||||
|
-> 按组内优先级、启用状态、并发/CPS、屏蔽地区选择落地网关
|
||||||
-> 执行主被叫前缀转换、时段和编码限制
|
-> 执行主被叫前缀转换、时段和编码限制
|
||||||
-> RTPEngine offer/answer
|
-> RTPEngine offer/answer
|
||||||
-> 转发到供应商
|
-> 转发到供应商
|
||||||
@@ -344,6 +347,10 @@ Redis Lua 脚本一次完成:
|
|||||||
"source_ip": "10.10.1.11",
|
"source_ip": "10.10.1.11",
|
||||||
"caller": "02160010001",
|
"caller": "02160010001",
|
||||||
"callee": "13800138000",
|
"callee": "13800138000",
|
||||||
|
"callee_city_code": "310000",
|
||||||
|
"callee_city_name": "上海",
|
||||||
|
"callee_province_name": "上海",
|
||||||
|
"callee_operator": "MOBILE",
|
||||||
"vendor_id": "V1001",
|
"vendor_id": "V1001",
|
||||||
"vendor_gateway_id": "VGW-001",
|
"vendor_gateway_id": "VGW-001",
|
||||||
"line_group_id": "LG-001",
|
"line_group_id": "LG-001",
|
||||||
@@ -412,11 +419,15 @@ customer_recharges
|
|||||||
vendors
|
vendors
|
||||||
vendor_recharges
|
vendor_recharges
|
||||||
vendor_gateways
|
vendor_gateways
|
||||||
|
vendor_gateway_blocked_regions
|
||||||
vendor_gateway_forbidden_periods
|
vendor_gateway_forbidden_periods
|
||||||
vendor_gateway_codecs
|
vendor_gateway_codecs
|
||||||
vendor_gateway_prefix_rules
|
vendor_gateway_prefix_rules
|
||||||
landing_line_groups
|
landing_line_groups
|
||||||
landing_line_group_items
|
landing_line_group_items
|
||||||
|
geo_cities
|
||||||
|
phone_number_segments
|
||||||
|
carrier_prefix_rules
|
||||||
raw_cdrs
|
raw_cdrs
|
||||||
rated_cdrs
|
rated_cdrs
|
||||||
recordings
|
recordings
|
||||||
@@ -440,6 +451,7 @@ idempotency_keys
|
|||||||
- 网关策略唯一索引至少包含 `gateway_id + priority`。
|
- 网关策略唯一索引至少包含 `gateway_id + priority`。
|
||||||
- 线路组成员唯一索引包含 `line_group_id + vendor_gateway_id`。
|
- 线路组成员唯一索引包含 `line_group_id + vendor_gateway_id`。
|
||||||
- CDR 使用 `event_id` 唯一索引。
|
- CDR 使用 `event_id` 唯一索引。
|
||||||
|
- CDR 需要保存被叫号码解析出的地级市、省份和运营商快照,避免后续号码库更新影响历史话单解释。
|
||||||
- 所有管理表包含 `created_at`、`updated_at`、`created_by`、`updated_by` 和乐观锁版本号。
|
- 所有管理表包含 `created_at`、`updated_at`、`created_by`、`updated_by` 和乐观锁版本号。
|
||||||
- 时间存 UTC;API 返回 ISO 8601。
|
- 时间存 UTC;API 返回 ISO 8601。
|
||||||
- 删除优先软删除;充值、CDR、审计日志不允许物理删除。
|
- 删除优先软删除;充值、CDR、审计日志不允许物理删除。
|
||||||
@@ -1135,7 +1147,7 @@ V2 首期仅支持落地网关上的:
|
|||||||
|
|
||||||
- 按菜单逐页替换 Mock;每次只接入一个领域。
|
- 按菜单逐页替换 Mock;每次只接入一个领域。
|
||||||
- 增加加载、空状态、错误、权限隐藏和并发更新提示。
|
- 增加加载、空状态、错误、权限隐藏和并发更新提示。
|
||||||
- 待设计菜单继续保留标识,不接临时 API。
|
- 待设计菜单不接临时 API;S30 后二期变更要求导航中隐藏这些入口。
|
||||||
|
|
||||||
**S28 三机端到端联调**
|
**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
|
```text
|
||||||
请先读取 SOFTSWITCH_PLATFORM_DESIGN_V2.md 和 IMPLEMENTATION_STATUS.md,
|
请先读取 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 { appConfig, validationSchema } from '../shared/config.js';
|
||||||
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
||||||
import { AuditModule } from './audit/audit.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 { 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 { CustomerGatewayPoliciesModule } from './customer-gateway-policies/customer-gateway-policies.module.js';
|
||||||
import { CustomerGatewaysModule } from './customer-gateways/customer-gateways.module.js';
|
import { CustomerGatewaysModule } from './customer-gateways/customer-gateways.module.js';
|
||||||
import { CustomersModule } from './customers/customers.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 { DatabaseModule } from './database/database.module.js';
|
||||||
import { HealthModule } from './health/health.module.js';
|
import { HealthModule } from './health/health.module.js';
|
||||||
import { LandingLineGroupsModule } from './landing-line-groups/landing-line-groups.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 { RechargesModule } from './recharges/recharges.module.js';
|
||||||
import { RecordingsModule } from './recordings/recordings.module.js';
|
import { RecordingsModule } from './recordings/recordings.module.js';
|
||||||
import { QualityModule } from './quality/quality.module.js';
|
import { QualityModule } from './quality/quality.module.js';
|
||||||
@@ -55,6 +58,8 @@ import { VendorsModule } from './vendors/vendors.module.js';
|
|||||||
SecurityModule,
|
SecurityModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
ActiveCallsModule,
|
||||||
|
CdrsModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
CustomerGatewaysModule,
|
CustomerGatewaysModule,
|
||||||
@@ -63,6 +68,7 @@ import { VendorsModule } from './vendors/vendors.module.js';
|
|||||||
VendorsModule,
|
VendorsModule,
|
||||||
VendorGatewaysModule,
|
VendorGatewaysModule,
|
||||||
LandingLineGroupsModule,
|
LandingLineGroupsModule,
|
||||||
|
NumberLibraryModule,
|
||||||
QualityModule,
|
QualityModule,
|
||||||
RecordingsModule,
|
RecordingsModule,
|
||||||
UsersModule,
|
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 { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { Public } from '../security/security.metadata.js';
|
import { Public } from '../security/security.metadata.js';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
|
import { CaptchaService } from './captcha.service.js';
|
||||||
import { parseCookie, serializeCookie } from './cookie.js';
|
import { parseCookie, serializeCookie } from './cookie.js';
|
||||||
|
|
||||||
interface LoginBody {
|
interface LoginBody {
|
||||||
username?: unknown;
|
username?: unknown;
|
||||||
password?: unknown;
|
password?: unknown;
|
||||||
|
captchaId?: unknown;
|
||||||
|
captchaCode?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Public()
|
@Public()
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
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')
|
@Post('login')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Login with username and password' })
|
@ApiOperation({ summary: 'Login with username and password' })
|
||||||
async login(@Body() body: LoginBody, @Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) {
|
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));
|
const result = await this.authService.login(username, password, this.requestContext(request));
|
||||||
|
|
||||||
this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds);
|
this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds);
|
||||||
@@ -56,8 +80,17 @@ export class AuthController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private readCredentials(body: LoginBody): { username: string; password: string } {
|
private readCredentials(body: LoginBody): { username: string; password: string; captchaId: string; captchaCode: string } {
|
||||||
if (typeof body.username !== 'string' || typeof body.password !== 'string' || !body.username.trim() || !body.password) {
|
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({
|
throw new UnauthorizedException({
|
||||||
code: 'AUTH_INVALID_CREDENTIALS',
|
code: 'AUTH_INVALID_CREDENTIALS',
|
||||||
message: 'Invalid username or password.'
|
message: 'Invalid username or password.'
|
||||||
@@ -66,7 +99,9 @@ export class AuthController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
username: body.username,
|
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 { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { hashPasswordArgon2id, sha256Token } from '@lisglosips/auth';
|
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';
|
import { AUTH_REPOSITORY, type AuthRepository, type AuthSessionRecord, type AuthUserRecord, type CreateSessionInput } from './auth.types.js';
|
||||||
|
|
||||||
class E2eAuthRepository implements AuthRepository {
|
class E2eAuthRepository implements AuthRepository {
|
||||||
@@ -77,6 +78,7 @@ class E2eAuthRepository implements AuthRepository {
|
|||||||
describe('LisgloSIPS Auth API', () => {
|
describe('LisgloSIPS Auth API', () => {
|
||||||
let app: NestFastifyApplication;
|
let app: NestFastifyApplication;
|
||||||
let repo: E2eAuthRepository;
|
let repo: E2eAuthRepository;
|
||||||
|
let captchaService: CaptchaService;
|
||||||
let secret: string;
|
let secret: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -101,6 +103,7 @@ describe('LisgloSIPS Auth API', () => {
|
|||||||
app.setGlobalPrefix('api/v2');
|
app.setGlobalPrefix('api/v2');
|
||||||
await app.init();
|
await app.init();
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
captchaService = app.get(CaptchaService);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -108,9 +111,20 @@ describe('LisgloSIPS Auth API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('logs in, refreshes with cookie rotation, and logs out', async () => {
|
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')
|
.post('/api/v2/auth/login')
|
||||||
.send({ username: 'operator', password: secret })
|
.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);
|
.expect(200);
|
||||||
const loginCookie = login.headers['set-cookie'][0];
|
const loginCookie = login.headers['set-cookie'][0];
|
||||||
const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? '';
|
const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? '';
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthController } from './auth.controller.js';
|
import { AuthController } from './auth.controller.js';
|
||||||
import { PrismaAuthRepository } from './auth.repository.js';
|
import { PrismaAuthRepository } from './auth.repository.js';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
|
import { CaptchaService } from './captcha.service.js';
|
||||||
import { AUTH_REPOSITORY } from './auth.types.js';
|
import { AUTH_REPOSITORY } from './auth.types.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
CaptchaService,
|
||||||
PrismaAuthRepository,
|
PrismaAuthRepository,
|
||||||
{
|
{
|
||||||
provide: AUTH_REPOSITORY,
|
provide: AUTH_REPOSITORY,
|
||||||
useExisting: PrismaAuthRepository
|
useExisting: PrismaAuthRepository
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
exports: [AuthService]
|
exports: [AuthService, CaptchaService]
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
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>([
|
const values = new Map<string, unknown>([
|
||||||
['auth.accessTokenSecret', 'test-only-access-token-secret-min-32-bytes'],
|
['auth.accessTokenSecret', 'test-only-access-token-secret-min-32-bytes'],
|
||||||
['auth.accessTokenTtlSeconds', 900],
|
['auth.accessTokenTtlSeconds', 900],
|
||||||
['auth.refreshTokenTtlDays', 7],
|
['auth.refreshTokenTtlDays', 7],
|
||||||
['auth.lockMaxFailures', 3],
|
['auth.lockMaxFailures', 3],
|
||||||
['auth.lockWindowSeconds', 60],
|
['auth.lockWindowSeconds', 60],
|
||||||
|
['auth.loginThrottleMaxFailures', 10],
|
||||||
|
['auth.loginThrottleWindowSeconds', 300],
|
||||||
['auth.tokenIssuer', 'lisglosips-api'],
|
['auth.tokenIssuer', 'lisglosips-api'],
|
||||||
['auth.tokenAudience', 'lisglosips-web']
|
['auth.tokenAudience', 'lisglosips-web']
|
||||||
]);
|
]);
|
||||||
|
Object.entries(overrides).forEach(([key, value]) => values.set(key, value));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: (key: string) => values.get(key)
|
get: (key: string) => values.get(key)
|
||||||
@@ -124,6 +127,15 @@ describe('AuthService', () => {
|
|||||||
await expect(service.login('operator', secret)).rejects.toMatchObject({ status: 401 });
|
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 () => {
|
it('rotates refresh sessions and rejects reuse', async () => {
|
||||||
const login = await service.login('operator', secret);
|
const login = await service.login('operator', secret);
|
||||||
const refresh = await service.refresh(login.tokens.refreshToken);
|
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 { ConfigService } from '@nestjs/config';
|
||||||
import {
|
import {
|
||||||
ACCESS_TOKEN_TYPE,
|
ACCESS_TOKEN_TYPE,
|
||||||
@@ -36,8 +36,15 @@ export interface AuthResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoginThrottleRecord {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
private readonly loginThrottle = new Map<string, LoginThrottleRecord>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository,
|
@Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository,
|
||||||
@Inject(ConfigService) private readonly config: ConfigService<RuntimeConfig, true>
|
@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 }> {
|
async login(username: string, password: string, context: AuthRequestContext = {}): Promise<{ response: AuthResponse; tokens: AuthTokens }> {
|
||||||
const normalizedUsername = username.trim().toLowerCase();
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
const user = await this.repository.findUserByUsername(normalizedUsername);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
this.assertLoginAllowed(normalizedUsername, context.ip, now);
|
||||||
|
|
||||||
|
const user = await this.repository.findUserByUsername(normalizedUsername);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
|
||||||
throw this.invalidCredentials();
|
throw this.invalidCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isLocked(user, now) || user.status !== 'ENABLED' || !user.passwordHash || user.passwordAlgo !== PASSWORD_ALGO_ARGON2ID) {
|
if (this.isLocked(user, now) || user.status !== 'ENABLED' || !user.passwordHash || user.passwordAlgo !== PASSWORD_ALGO_ARGON2ID) {
|
||||||
await this.registerFailure(user, now);
|
await this.registerFailure(user, now);
|
||||||
|
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
|
||||||
throw this.invalidCredentials();
|
throw this.invalidCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await verifyPasswordArgon2id(password, user.passwordHash))) {
|
if (!(await verifyPasswordArgon2id(password, user.passwordHash))) {
|
||||||
await this.registerFailure(user, now);
|
await this.registerFailure(user, now);
|
||||||
|
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
|
||||||
throw this.invalidCredentials();
|
throw this.invalidCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearLoginThrottleForUser(normalizedUsername);
|
||||||
await this.repository.markLoginSuccess(user.id, context.ip);
|
await this.repository.markLoginSuccess(user.id, context.ip);
|
||||||
const tokens = await this.issueTokens(user, context);
|
const tokens = await this.issueTokens(user, context);
|
||||||
|
|
||||||
@@ -175,6 +189,53 @@ export class AuthService {
|
|||||||
return Boolean(user.lockedUntil && user.lockedUntil > now);
|
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 {
|
private invalidCredentials(): UnauthorizedException {
|
||||||
return new UnauthorizedException({
|
return new UnauthorizedException({
|
||||||
code: 'AUTH_INVALID_CREDENTIALS',
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { AuditAction } from '../audit/audit.metadata.js';
|
import { AuditAction } from '../audit/audit.metadata.js';
|
||||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.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) {
|
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||||
return this.customerGatewaysService.disable(id, currentUser?.id);
|
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;
|
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: {
|
private summary(input: {
|
||||||
id: string;
|
id: string;
|
||||||
customerId?: 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/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()).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>;
|
create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||||
update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||||
setStatus(gatewayId: string, status: CustomerGatewayStatus, actorId?: string): 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');
|
export const CUSTOMER_GATEWAYS_REPOSITORY = Symbol('CUSTOMER_GATEWAYS_REPOSITORY');
|
||||||
@@ -168,6 +169,35 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
|||||||
return this.toSummary(gateway);
|
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> {
|
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise<void> {
|
||||||
await tx.outboxEvent.create({
|
await tx.outboxEvent.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ export class CustomerGatewaysService {
|
|||||||
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
|
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 {
|
private limitedString(value: unknown, field: string, maxLength: number): string {
|
||||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
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),
|
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
|
||||||
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
||||||
policyCount: input.policyCount ?? 0,
|
policyCount: input.policyCount ?? 0,
|
||||||
|
customerGatewayCount: input.customerGatewayCount ?? 0,
|
||||||
items,
|
items,
|
||||||
createdAt: new Date('2026-06-21T07:00:00.000Z'),
|
createdAt: new Date('2026-06-21T07:00:00.000Z'),
|
||||||
updatedAt: 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;
|
concurrencyCapSum: number;
|
||||||
gatewayConcurrencyLimitSum: number;
|
gatewayConcurrencyLimitSum: number;
|
||||||
policyCount: number;
|
policyCount: number;
|
||||||
|
customerGatewayCount: number;
|
||||||
items: LineGroupItemSummary[];
|
items: LineGroupItemSummary[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -139,11 +140,14 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
|||||||
orderBy: [{ createdAt: 'desc' }],
|
orderBy: [{ createdAt: 'desc' }],
|
||||||
include: this.includeSummary()
|
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> {
|
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> {
|
async create(input: CreateLineGroupInput): Promise<LineGroupSummary> {
|
||||||
@@ -201,11 +205,10 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
|||||||
|
|
||||||
async softDelete(lineGroupIdValue: string, actorId?: string): Promise<LineGroupSummary> {
|
async softDelete(lineGroupIdValue: string, actorId?: string): Promise<LineGroupSummary> {
|
||||||
await this.findActiveOrThrow(lineGroupIdValue);
|
await this.findActiveOrThrow(lineGroupIdValue);
|
||||||
const linkedPolicies = await this.prisma.customerGatewayPolicy.count({
|
const usageCounts = await this.customerGatewayUsageCounts([lineGroupIdValue]);
|
||||||
where: { lineGroupId: lineGroupIdValue, deletedAt: null }
|
const linkedCustomerGateways = usageCounts.get(lineGroupIdValue) ?? 0;
|
||||||
});
|
if (linkedCustomerGateways > 0) {
|
||||||
if (linkedPolicies > 0) {
|
throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active customer gateways cannot be deleted.' });
|
||||||
throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active policies cannot be deleted.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = await this.prisma.$transaction(async (tx) => {
|
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');
|
await this.enqueueConfigOutbox(tx, deleted.id, 'line_group.deleted');
|
||||||
return deleted;
|
return deleted;
|
||||||
});
|
});
|
||||||
return this.toSummary(group);
|
return this.toSummary(group, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary> {
|
async addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary> {
|
||||||
@@ -342,6 +345,27 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
|||||||
return this.get(lineGroupIdValue);
|
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() {
|
private includeItem() {
|
||||||
return {
|
return {
|
||||||
vendorGateway: {
|
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 items = group.items.map((item) => this.toItemSummary(item));
|
||||||
const enabledItems = items.filter((item) => item.status === 'ENABLED');
|
const enabledItems = items.filter((item) => item.status === 'ENABLED');
|
||||||
return {
|
return {
|
||||||
@@ -440,6 +464,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
|||||||
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
|
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
|
||||||
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
||||||
policyCount: group._count.policies,
|
policyCount: group._count.policies,
|
||||||
|
customerGatewayCount,
|
||||||
items,
|
items,
|
||||||
createdAt: group.createdAt,
|
createdAt: group.createdAt,
|
||||||
updatedAt: group.updatedAt
|
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]> {
|
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([
|
const [items, total] = await this.prisma.$transaction([
|
||||||
this.prisma.customerRecharge.findMany({
|
this.prisma.customerRecharge.findMany({
|
||||||
where,
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { AuditAction } from '../audit/audit.metadata.js';
|
import { AuditAction } from '../audit/audit.metadata.js';
|
||||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.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);
|
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')
|
@Get('permissions')
|
||||||
@RequirePermissions('roles.view')
|
@RequirePermissions('roles.view')
|
||||||
listPermissions() {
|
listPermissions() {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface RolesRepository {
|
|||||||
listPermissions(): Promise<PermissionSummary[]>;
|
listPermissions(): Promise<PermissionSummary[]>;
|
||||||
createRole(input: CreateRoleInput): Promise<RoleSummary>;
|
createRole(input: CreateRoleInput): Promise<RoleSummary>;
|
||||||
updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary>;
|
updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary>;
|
||||||
|
softDeleteRole(roleId: string, actorId?: string): Promise<RoleSummary>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROLES_REPOSITORY = Symbol('ROLES_REPOSITORY');
|
export const ROLES_REPOSITORY = Symbol('ROLES_REPOSITORY');
|
||||||
@@ -169,6 +170,54 @@ export class PrismaRolesRepository implements RolesRepository {
|
|||||||
return this.toSummary(role);
|
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> {
|
private async ensurePermissions(permissionIds: string[]): Promise<void> {
|
||||||
const uniquePermissionIds = [...new Set(permissionIds)];
|
const uniquePermissionIds = [...new Set(permissionIds)];
|
||||||
const count = await this.prisma.permission.count({
|
const count = await this.prisma.permission.count({
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export class RolesService {
|
|||||||
return this.roles.updateRole(roleId, input);
|
return this.roles.updateRole(roleId, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeRole(roleId: string, actorId?: string) {
|
||||||
|
return this.roles.softDeleteRole(roleId, actorId);
|
||||||
|
}
|
||||||
|
|
||||||
private requiredString(value: unknown, field: string): string {
|
private requiredString(value: unknown, field: string): string {
|
||||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
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);
|
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 {
|
private summary(id: string, username: string, roleIds: string[], requirePasswordChange = false): UserSummary {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -125,6 +129,10 @@ class MemoryRolesRepository implements RolesRepository {
|
|||||||
return this.role(roleId, roleId === 'ROLE_SUPER_ADMIN', input.permissionIds ?? ['users.manage']);
|
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 {
|
private role(id: string, builtIn: boolean, permissionIds: string[], name = 'role'): RoleSummary {
|
||||||
return {
|
return {
|
||||||
id,
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { AuditAction } from '../audit/audit.metadata.js';
|
import { AuditAction } from '../audit/audit.metadata.js';
|
||||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.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) {
|
resetPassword(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||||
return this.usersService.resetPassword(id, body as never, currentUser?.id);
|
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>;
|
create(input: CreateUserInput): Promise<UserSummary>;
|
||||||
update(userId: string, input: UpdateUserInput): Promise<UserSummary>;
|
update(userId: string, input: UpdateUserInput): Promise<UserSummary>;
|
||||||
resetPassword(userId: string, passwordHash: string, passwordAlgo: string, actorId?: string): 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');
|
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> {
|
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 },
|
where: { id: userIdValue },
|
||||||
data: {
|
data: {
|
||||||
passwordHash,
|
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);
|
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);
|
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 {
|
private requiredString(value: unknown, field: string): string {
|
||||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { AuditAction } from '../audit/audit.metadata.js';
|
import { AuditAction } from '../audit/audit.metadata.js';
|
||||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.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) {
|
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||||
return this.vendorGatewaysService.disable(id, currentUser?.id);
|
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 });
|
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 {
|
private summary(input: Partial<VendorGatewaySummary> & { id: string; name: string }): VendorGatewaySummary {
|
||||||
const cycleRate = input.cycleRate ?? '0.000000';
|
const cycleRate = input.cycleRate ?? '0.000000';
|
||||||
const billingCycleSec = input.billingCycleSec ?? 60;
|
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/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()).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 crypto from 'node:crypto';
|
||||||
import { Prisma } from '@lisglosips/database';
|
import { Prisma } from '@lisglosips/database';
|
||||||
import { PrismaService } from '../database/prisma.service.js';
|
import { PrismaService } from '../database/prisma.service.js';
|
||||||
@@ -100,6 +100,7 @@ export interface VendorGatewaysRepository {
|
|||||||
create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||||
update(gatewayId: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
update(gatewayId: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||||
setStatus(gatewayId: string, status: VendorGatewayStatus, actorId?: string): 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');
|
export const VENDOR_GATEWAYS_REPOSITORY = Symbol('VENDOR_GATEWAYS_REPOSITORY');
|
||||||
@@ -244,6 +245,38 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
|||||||
return this.toSummary(gateway);
|
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> {
|
private async ensureVendor(vendorId: string): Promise<void> {
|
||||||
const vendor = await this.prisma.vendor.findUnique({
|
const vendor = await this.prisma.vendor.findUnique({
|
||||||
where: { id: vendorId },
|
where: { id: vendorId },
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export class VendorGatewaysService {
|
|||||||
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
|
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 {
|
private limitedString(value: unknown, field: string, maxLength: number): string {
|
||||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ export interface RuntimeConfig {
|
|||||||
redis: {
|
redis: {
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
activeCalls: {
|
||||||
|
sshHost: string;
|
||||||
|
sshConfig?: string;
|
||||||
|
remoteCommand: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
};
|
||||||
auth: {
|
auth: {
|
||||||
accessTokenSecret: string;
|
accessTokenSecret: string;
|
||||||
accessTokenTtlSeconds: number;
|
accessTokenTtlSeconds: number;
|
||||||
refreshTokenTtlDays: number;
|
refreshTokenTtlDays: number;
|
||||||
lockMaxFailures: number;
|
lockMaxFailures: number;
|
||||||
lockWindowSeconds: number;
|
lockWindowSeconds: number;
|
||||||
|
loginThrottleMaxFailures: number;
|
||||||
|
loginThrottleWindowSeconds: number;
|
||||||
cookieSecure: boolean;
|
cookieSecure: boolean;
|
||||||
tokenIssuer: string;
|
tokenIssuer: string;
|
||||||
tokenAudience: string;
|
tokenAudience: string;
|
||||||
@@ -39,6 +47,10 @@ export const validationSchema = Joi.object({
|
|||||||
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
|
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
|
||||||
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
|
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
|
||||||
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
|
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
|
||||||
|
ACTIVE_CALLS_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', {
|
AUTH_ACCESS_TOKEN_SECRET: Joi.string().min(32).when('NODE_ENV', {
|
||||||
is: 'production',
|
is: 'production',
|
||||||
then: Joi.required(),
|
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_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_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_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_ISSUER: Joi.string().default('lisglosips-api'),
|
||||||
AUTH_TOKEN_AUDIENCE: Joi.string().default('lisglosips-web')
|
AUTH_TOKEN_AUDIENCE: Joi.string().default('lisglosips-web')
|
||||||
});
|
});
|
||||||
|
|
||||||
export function appConfig(): RuntimeConfig {
|
export function appConfig(): RuntimeConfig {
|
||||||
|
const cookieSecureValue = (process.env.AUTH_COOKIE_SECURE ?? 'true').trim().replace(/^['"]|['"]$/g, '').toLowerCase();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
service: {
|
service: {
|
||||||
name: process.env.LISGLOSIPS_SERVICE_NAME ?? 'api'
|
name: process.env.LISGLOSIPS_SERVICE_NAME ?? 'api'
|
||||||
@@ -72,13 +88,21 @@ export function appConfig(): RuntimeConfig {
|
|||||||
redis: {
|
redis: {
|
||||||
url: process.env.REDIS_URL ?? ''
|
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: {
|
auth: {
|
||||||
accessTokenSecret: process.env.AUTH_ACCESS_TOKEN_SECRET ?? 'dev-only-change-this-auth-secret-32-bytes-min',
|
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),
|
accessTokenTtlSeconds: Number(process.env.AUTH_ACCESS_TOKEN_TTL_SECONDS ?? 900),
|
||||||
refreshTokenTtlDays: Number(process.env.AUTH_REFRESH_TOKEN_TTL_DAYS ?? 7),
|
refreshTokenTtlDays: Number(process.env.AUTH_REFRESH_TOKEN_TTL_DAYS ?? 7),
|
||||||
lockMaxFailures: Number(process.env.AUTH_LOCK_MAX_FAILURES ?? 5),
|
lockMaxFailures: Number(process.env.AUTH_LOCK_MAX_FAILURES ?? 5),
|
||||||
lockWindowSeconds: Number(process.env.AUTH_LOCK_WINDOW_SECONDS ?? 900),
|
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',
|
tokenIssuer: process.env.AUTH_TOKEN_ISSUER ?? 'lisglosips-api',
|
||||||
tokenAudience: process.env.AUTH_TOKEN_AUDIENCE ?? 'lisglosips-web'
|
tokenAudience: process.env.AUTH_TOKEN_AUDIENCE ?? 'lisglosips-web'
|
||||||
}
|
}
|
||||||
|
|||||||
+1247
-105
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 API_BASE = (import.meta.env.VITE_LISGLOSIPS_API_BASE || '/api/v2').replace(/\/$/, '');
|
||||||
const ACCESS_TOKEN_KEY = 'lisglosips.accessToken';
|
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 {
|
export class ApiError extends Error {
|
||||||
constructor(message, { status, code } = {}) {
|
constructor(message, { status, code } = {}) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -55,12 +67,39 @@ function idempotencyKey(scope) {
|
|||||||
return `${scope}:${random}`;
|
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 = {
|
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'),
|
dashboardSummary: () => request('/dashboard/summary'),
|
||||||
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }) => request(`/dashboard/trends?${new URLSearchParams(params)}`),
|
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'),
|
customers: () => request('/customers'),
|
||||||
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
|
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
|
||||||
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||||
|
deleteCustomer: (id) => request(`/customers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
rechargeCustomer: (id, body) =>
|
rechargeCustomer: (id, body) =>
|
||||||
request(`/customers/${encodeURIComponent(id)}/recharges`, {
|
request(`/customers/${encodeURIComponent(id)}/recharges`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -69,20 +108,68 @@ export const api = {
|
|||||||
vendors: () => request('/vendors'),
|
vendors: () => request('/vendors'),
|
||||||
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
|
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
|
||||||
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||||
|
deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
rechargeVendor: (id, body) =>
|
rechargeVendor: (id, body) =>
|
||||||
request(`/vendors/${encodeURIComponent(id)}/recharges`, {
|
request(`/vendors/${encodeURIComponent(id)}/recharges`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
|
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'),
|
recharges: () => request('/recharges?take=100'),
|
||||||
users: () => request('/users'),
|
users: () => request('/users'),
|
||||||
|
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
roles: () => request('/roles'),
|
roles: () => request('/roles'),
|
||||||
|
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
auditLogs: () => request('/audit-logs?take=100'),
|
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) {
|
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)) {
|
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 : '请求失败,请稍后重试。';
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,80 @@ button:disabled {
|
|||||||
border: 0;
|
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 {
|
.prototype-app {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px minmax(0, 1fr);
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
@@ -668,6 +742,11 @@ button:disabled {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.muted-text {
|
||||||
|
color: var(--muted);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-head {
|
.modal-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -759,6 +838,12 @@ button:disabled {
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.proto-table .table-cell-compact {
|
||||||
|
max-width: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.proto-table tbody tr {
|
.proto-table tbody tr {
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -850,6 +935,80 @@ button:disabled {
|
|||||||
font-weight: 800;
|
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,
|
.rank-list div,
|
||||||
.ops-row {
|
.ops-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1444,6 +1603,12 @@ button:disabled {
|
|||||||
border-color: #b9e8c7;
|
border-color: #b9e8c7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-alert-danger {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -1594,10 +1759,19 @@ button:disabled {
|
|||||||
.match-grid,
|
.match-grid,
|
||||||
.option-grid,
|
.option-grid,
|
||||||
.rate-config-fields,
|
.rate-config-fields,
|
||||||
|
.cdr-detail-hero,
|
||||||
|
.cdr-detail-strip,
|
||||||
|
.cdr-detail-grid,
|
||||||
|
.cdr-timeline,
|
||||||
.code-grid {
|
.code-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cdr-detail-hero svg {
|
||||||
|
justify-self: center;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
.inline-control,
|
.inline-control,
|
||||||
.time-range,
|
.time-range,
|
||||||
.prefix-rule {
|
.prefix-rule {
|
||||||
@@ -1615,6 +1789,15 @@ button:disabled {
|
|||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.captcha-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-image,
|
||||||
|
.captcha-image img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-card strong {
|
.metric-card strong {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { Prisma } from '@lisglosips/database';
|
|||||||
import type { ParsedCdrStreamEvent } from '@lisglosips/redis';
|
import type { ParsedCdrStreamEvent } from '@lisglosips/redis';
|
||||||
|
|
||||||
import { calculateCycleCharge } from './billing.js';
|
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 {
|
class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
|
||||||
rawByEventId = new Map<string, { id: string; eventId: string; ratingStatus: 'UNRATED' | 'RATED' | 'SKIPPED' | 'FAILED' }>();
|
rawByEventId = new Map<string, { id: string; eventId: string; ratingStatus: 'UNRATED' | 'RATED' | 'SKIPPED' | 'FAILED' }>();
|
||||||
ratedByRawId = new Map<string, { id: string; rawCdrId: string; customerFee: Prisma.Decimal }>();
|
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') };
|
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') };
|
customer = { id: 'cus_1', balance: new Prisma.Decimal('10.000000') };
|
||||||
charges: CreateCustomerChargeInput[] = [];
|
|
||||||
|
|
||||||
async transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T> {
|
async transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T> {
|
||||||
return operation(this);
|
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> {
|
async updateCustomerBalance(_customerId: string, balance: Prisma.Decimal): Promise<void> {
|
||||||
this.customer.balance = balance;
|
this.customer.balance = balance;
|
||||||
}
|
}
|
||||||
@@ -78,6 +73,11 @@ function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEv
|
|||||||
source_ip: '100.93.185.30',
|
source_ip: '100.93.185.30',
|
||||||
caller: '1001',
|
caller: '1001',
|
||||||
callee: '13800138000',
|
callee: '13800138000',
|
||||||
|
callee_city_code: '340100',
|
||||||
|
callee_city_name: '合肥市',
|
||||||
|
callee_province_name: '安徽省',
|
||||||
|
callee_operator: 'MOBILE',
|
||||||
|
callee_number_type: 'MOBILE',
|
||||||
vendor_id: 'ven_1',
|
vendor_id: 'ven_1',
|
||||||
vendor_gateway_id: 'vgw_1',
|
vendor_gateway_id: 'vgw_1',
|
||||||
line_group_id: 'lg_1',
|
line_group_id: 'lg_1',
|
||||||
@@ -105,7 +105,7 @@ describe('S23 CDR rating', () => {
|
|||||||
expect(charge.amount.toFixed(6)).toBe('0.060000');
|
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 store = new MemoryStore();
|
||||||
const service = new CdrRatingService(store);
|
const service = new CdrRatingService(store);
|
||||||
|
|
||||||
@@ -121,8 +121,6 @@ describe('S23 CDR rating', () => {
|
|||||||
});
|
});
|
||||||
expect(duplicate.outcome).toBe('duplicate');
|
expect(duplicate.outcome).toBe('duplicate');
|
||||||
expect(store.customer.balance.toFixed(6)).toBe('9.940000');
|
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 () => {
|
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.outcome).toBe('skipped');
|
||||||
expect(result.customerFee).toBe('0.000000');
|
expect(result.customerFee).toBe('0.000000');
|
||||||
expect(store.customer.balance.toFixed(6)).toBe('10.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>;
|
lockCustomer(customerId: string): Promise<LockedCustomer | null>;
|
||||||
createRated(input: CreateRatedInput): Promise<StoredRatedCdr>;
|
createRated(input: CreateRatedInput): Promise<StoredRatedCdr>;
|
||||||
markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise<void>;
|
markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise<void>;
|
||||||
createCustomerCharge(input: CreateCustomerChargeInput): Promise<void>;
|
|
||||||
updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise<void>;
|
updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,16 +66,6 @@ export interface CreateRatedInput {
|
|||||||
vendorRate: Prisma.InputJsonValue;
|
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 {
|
export class CdrRatingService {
|
||||||
constructor(private readonly store: CdrRatingStore) {}
|
constructor(private readonly store: CdrRatingStore) {}
|
||||||
|
|
||||||
@@ -144,15 +133,6 @@ export class CdrRatingService {
|
|||||||
cycleRate: vendorGateway.cycleRate.toFixed(6)
|
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.updateCustomerBalance(customer.id, afterBalance);
|
||||||
await tx.markRawStatus(raw.id, 'RATED');
|
await tx.markRawStatus(raw.id, 'RATED');
|
||||||
|
|
||||||
@@ -202,6 +182,11 @@ class PrismaCdrRatingTransaction implements CdrRatingTransaction {
|
|||||||
sourceIp: emptyToNull(event.source_ip),
|
sourceIp: emptyToNull(event.source_ip),
|
||||||
caller: event.caller || 'unknown',
|
caller: event.caller || 'unknown',
|
||||||
callee: event.callee || '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),
|
vendorId: nullableId(event.vendor_id),
|
||||||
vendorGatewayId: nullableId(event.vendor_gateway_id),
|
vendorGatewayId: nullableId(event.vendor_gateway_id),
|
||||||
lineGroupId: nullableId(event.line_group_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> {
|
async updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise<void> {
|
||||||
await this.tx.customer.update({
|
await this.tx.customer.update({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
@@ -326,7 +295,7 @@ function parseOptionalInt(value: string): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = Number.parseInt(value, 10);
|
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 {
|
function parseOptionalCdrDate(value: string): Date | null {
|
||||||
@@ -361,6 +330,11 @@ function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
|
|||||||
source_ip: event.source_ip,
|
source_ip: event.source_ip,
|
||||||
caller: event.caller,
|
caller: event.caller,
|
||||||
callee: event.callee,
|
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_id: event.customer_id,
|
||||||
customer_gateway_id: event.customer_gateway_id,
|
customer_gateway_id: event.customer_gateway_id,
|
||||||
customer_gateway_policy_id: event.customer_gateway_policy_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
|
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;
|
policyCount: number;
|
||||||
vendorGatewayCount: number;
|
vendorGatewayCount: number;
|
||||||
lineGroupCount: number;
|
lineGroupCount: number;
|
||||||
|
cityCount: number;
|
||||||
|
phoneSegmentCount: number;
|
||||||
|
areaCodeCount: number;
|
||||||
|
carrierPrefixRuleCount: number;
|
||||||
|
blockedRegionCount: number;
|
||||||
checksum: string;
|
checksum: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +79,13 @@ type ConfigSnapshot = {
|
|||||||
forbiddenPeriods: Array<{ weekdayMask: number; startTime: string; endTime: string }>;
|
forbiddenPeriods: Array<{ weekdayMask: number; startTime: string; endTime: string }>;
|
||||||
codecs: Array<{ codec: string; priority: number }>;
|
codecs: Array<{ codec: string; priority: number }>;
|
||||||
prefixRules: Array<{ direction: string; matchPrefix: string; replacePrefix: 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<{
|
lineGroups: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -88,12 +100,43 @@ type ConfigSnapshot = {
|
|||||||
status: string;
|
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> {
|
export async function publishPendingConfig(prisma: PrismaClient, redis: RedisClient, now = new Date()): Promise<PublishConfigResult> {
|
||||||
const pending = await prisma.outboxEvent.findMany({
|
const pending = await prisma.outboxEvent.findMany({
|
||||||
where: {
|
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',
|
status: 'PENDING',
|
||||||
availableAt: { lte: now }
|
availableAt: { lte: now }
|
||||||
},
|
},
|
||||||
@@ -174,7 +217,7 @@ export async function rollbackActiveConfig(redis: RedisClient): Promise<{ rolled
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
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({
|
prisma.customer.findMany({
|
||||||
where: { deletedAt: null },
|
where: { deletedAt: null },
|
||||||
orderBy: [{ id: 'asc' }]
|
orderBy: [{ id: 'asc' }]
|
||||||
@@ -193,7 +236,8 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
|||||||
include: {
|
include: {
|
||||||
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
|
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
|
||||||
codecs: { orderBy: [{ priority: '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({
|
prisma.landingLineGroup.findMany({
|
||||||
@@ -202,6 +246,24 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
|||||||
include: {
|
include: {
|
||||||
items: { orderBy: [{ priority: 'asc' }] }
|
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,
|
matchPrefix: rule.matchPrefix,
|
||||||
replacePrefix: rule.replacePrefix,
|
replacePrefix: rule.replacePrefix,
|
||||||
priority: rule.priority
|
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) => ({
|
lineGroups: lineGroups.map((group) => ({
|
||||||
@@ -277,6 +346,37 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
|||||||
concurrencyCap: item.concurrencyCap,
|
concurrencyCap: item.concurrencyCap,
|
||||||
status: item.status
|
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,
|
policyCount: snapshot.policies.length,
|
||||||
vendorGatewayCount: snapshot.vendorGateways.length,
|
vendorGatewayCount: snapshot.vendorGateways.length,
|
||||||
lineGroupCount: snapshot.lineGroups.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
|
checksum
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,6 +426,14 @@ async function writeSnapshot(
|
|||||||
}
|
}
|
||||||
for (const gateway of snapshot.vendorGateways) {
|
for (const gateway of snapshot.vendorGateways) {
|
||||||
multi.set(`${prefix}:vendor_gateway:${gateway.id}`, JSON.stringify(gateway));
|
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) {
|
for (const lineGroup of snapshot.lineGroups) {
|
||||||
multi.set(`${prefix}:line_group:${lineGroup.id}`, JSON.stringify(lineGroup));
|
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));
|
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) {
|
if (previousVersion) {
|
||||||
multi.set(CONFIG_PREVIOUS_VERSION_KEY, 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 "sl.so"
|
||||||
loadmodule "tm.so"
|
loadmodule "tm.so"
|
||||||
modparam("tm", "fr_timeout", 5)
|
modparam("tm", "fr_timeout", 5)
|
||||||
modparam("tm", "fr_inv_timeout", 30)
|
modparam("tm", "fr_inv_timeout", 95)
|
||||||
|
|
||||||
loadmodule "rr.so"
|
loadmodule "rr.so"
|
||||||
modparam("rr", "append_fromtag", 1)
|
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 "maxfwd.so"
|
||||||
loadmodule "sipmsgops.so"
|
loadmodule "sipmsgops.so"
|
||||||
loadmodule "textops.so"
|
loadmodule "textops.so"
|
||||||
@@ -167,10 +172,21 @@ route[S28_INVITE] {
|
|||||||
$var(s28_line_group_id) = "none";
|
$var(s28_line_group_id) = "none";
|
||||||
$var(s28_policy_id) = "none";
|
$var(s28_policy_id) = "none";
|
||||||
$var(s28_customer_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_code) = 503;
|
||||||
$var(s28_reply_text) = "Routing Not Ready";
|
$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");
|
xlog("L_ERR", "S28 Redis hotpath unavailable source=$si callid=$ci\n");
|
||||||
$var(s28_reason) = "REDIS_UNAVAILABLE";
|
$var(s28_reason) = "REDIS_UNAVAILABLE";
|
||||||
$var(s28_reply_code) = 503;
|
$var(s28_reply_code) = 503;
|
||||||
@@ -187,13 +203,24 @@ route[S28_INVITE] {
|
|||||||
$var(s28_line_group_id) = $(avp(s28_hotpath)[4]);
|
$var(s28_line_group_id) = $(avp(s28_hotpath)[4]);
|
||||||
$var(s28_policy_id) = $(avp(s28_hotpath)[5]);
|
$var(s28_policy_id) = $(avp(s28_hotpath)[5]);
|
||||||
$var(s28_customer_id) = $(avp(s28_hotpath)[6]);
|
$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") {
|
if ($var(s28_decision) != "allow") {
|
||||||
update_stat("s28_hotpath_reject_total", 1);
|
update_stat("s28_hotpath_reject_total", 1);
|
||||||
if ($var(s28_reason) == "CONFIG_MISSING") {
|
if ($var(s28_reason) == "CONFIG_MISSING") {
|
||||||
$var(s28_reply_code) = 503;
|
$var(s28_reply_code) = 503;
|
||||||
$var(s28_reply_text) = "Config Missing";
|
$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_code) = 503;
|
||||||
$var(s28_reply_text) = "No Route Policy";
|
$var(s28_reply_text) = "No Route Policy";
|
||||||
} else {
|
} else {
|
||||||
@@ -206,7 +233,30 @@ route[S28_INVITE] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_stat("s28_hotpath_allow_total", 1);
|
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 (has_body("application/sdp")) {
|
||||||
if (!rtpengine_offer("replace-origin replace-session-connection record-call=on")) {
|
if (!rtpengine_offer("replace-origin replace-session-connection record-call=on")) {
|
||||||
@@ -221,8 +271,8 @@ route[S28_INVITE] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
record_route();
|
record_route();
|
||||||
$du = "sip:100.93.185.30:50620";
|
$du = "sip:" + $var(s28_vendor_host) + ":" + $var(s28_vendor_port);
|
||||||
$ru = "sip:" + $rU + "@100.93.185.30:50620";
|
$ru = "sip:" + $rU + "@" + $var(s28_vendor_host) + ":" + $var(s28_vendor_port);
|
||||||
t_on_reply("S28_REPLY");
|
t_on_reply("S28_REPLY");
|
||||||
|
|
||||||
if (!t_relay()) {
|
if (!t_relay()) {
|
||||||
@@ -247,7 +297,7 @@ onreply_route[S28_REPLY] {
|
|||||||
route[S28_CDR_SUCCESS] {
|
route[S28_CDR_SUCCESS] {
|
||||||
$var(s28_event_id) = "s28-ok-" + $Ts + "-" + $pp + "-" + $ci;
|
$var(s28_event_id) = "s28-ok-" + $Ts + "-" + $pp + "-" + $ci;
|
||||||
$var(s28_idempotency_key) = $ci + ":s28-ok:" + $Ts;
|
$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);
|
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");
|
xlog("L_INFO", "S28 success CDR XADD ok redis_id=$avp(s28_cdr_id) event_id=$var(s28_event_id) callid=$ci\n");
|
||||||
} else {
|
} else {
|
||||||
@@ -259,7 +309,7 @@ route[S28_CDR_SUCCESS] {
|
|||||||
route[S28_CDR_FAILURE] {
|
route[S28_CDR_FAILURE] {
|
||||||
$var(s28_event_id) = "s28-fail-" + $Ts + "-" + $pp + "-" + $ci;
|
$var(s28_event_id) = "s28-fail-" + $Ts + "-" + $pp + "-" + $ci;
|
||||||
$var(s28_idempotency_key) = $ci + ":s28-fail:" + $Ts;
|
$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);
|
update_stat("s28_cdr_xadd_total", 1);
|
||||||
} else {
|
} else {
|
||||||
update_stat("s28_cdr_xadd_error_total", 1);
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "same-origin" always;
|
add_header Referrer-Policy "same-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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 =
|
export type PermissionKey =
|
||||||
| 'dashboard.view'
|
| 'dashboard.view'
|
||||||
|
| 'active_calls.view'
|
||||||
|
| 'active_calls.manage'
|
||||||
| 'customers.view'
|
| 'customers.view'
|
||||||
| 'customers.manage'
|
| 'customers.manage'
|
||||||
| 'customer_gateways.view'
|
| 'customer_gateways.view'
|
||||||
@@ -15,6 +17,8 @@ export type PermissionKey =
|
|||||||
| 'vendor_gateways.manage'
|
| 'vendor_gateways.manage'
|
||||||
| 'line_groups.view'
|
| 'line_groups.view'
|
||||||
| 'line_groups.manage'
|
| 'line_groups.manage'
|
||||||
|
| 'number_library.view'
|
||||||
|
| 'number_library.manage'
|
||||||
| 'recharges.view'
|
| 'recharges.view'
|
||||||
| 'recharges.manage'
|
| 'recharges.manage'
|
||||||
| 'cdr.view'
|
| 'cdr.view'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|||||||
const schema = readFileSync(join(process.cwd(), 'prisma/schema.prisma'), 'utf8');
|
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 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 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', () => {
|
describe('Prisma schema contract', () => {
|
||||||
it('defines every V2 core business table', () => {
|
it('defines every V2 core business table', () => {
|
||||||
@@ -16,11 +17,16 @@ describe('Prisma schema contract', () => {
|
|||||||
'vendors',
|
'vendors',
|
||||||
'vendor_recharges',
|
'vendor_recharges',
|
||||||
'vendor_gateways',
|
'vendor_gateways',
|
||||||
|
'vendor_gateway_blocked_regions',
|
||||||
'vendor_gateway_forbidden_periods',
|
'vendor_gateway_forbidden_periods',
|
||||||
'vendor_gateway_codecs',
|
'vendor_gateway_codecs',
|
||||||
'vendor_gateway_prefix_rules',
|
'vendor_gateway_prefix_rules',
|
||||||
'landing_line_groups',
|
'landing_line_groups',
|
||||||
'landing_line_group_items',
|
'landing_line_group_items',
|
||||||
|
'geo_cities',
|
||||||
|
'phone_number_segments',
|
||||||
|
'phone_area_codes',
|
||||||
|
'carrier_prefix_rules',
|
||||||
'raw_cdrs',
|
'raw_cdrs',
|
||||||
'rated_cdrs',
|
'rated_cdrs',
|
||||||
'recordings',
|
'recordings',
|
||||||
@@ -39,7 +45,7 @@ describe('Prisma schema contract', () => {
|
|||||||
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
expect(schema).toContain(`@@map("${table}")`);
|
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`');
|
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', () => {
|
it('models audit columns and outbox publishing state', () => {
|
||||||
expect(schema).toContain('createdAt');
|
expect(schema).toContain('createdAt');
|
||||||
expect(schema).toContain('updatedAt');
|
expect(schema).toContain('updatedAt');
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ function sampleEvent(overrides: Partial<CdrStreamPublishInput> = {}): CdrStreamP
|
|||||||
sourceIp: '100.93.185.30',
|
sourceIp: '100.93.185.30',
|
||||||
caller: 's21-ip-1001',
|
caller: 's21-ip-1001',
|
||||||
callee: '13800138000',
|
callee: '13800138000',
|
||||||
|
calleeCityCode: '340100',
|
||||||
|
calleeCityName: '合肥市',
|
||||||
|
calleeProvinceName: '安徽省',
|
||||||
|
calleeOperator: 'MOBILE',
|
||||||
|
calleeNumberType: 'MOBILE',
|
||||||
sipCode: 503,
|
sipCode: 503,
|
||||||
hangupReason: 'CONFIG_MISSING',
|
hangupReason: 'CONFIG_MISSING',
|
||||||
configVersion: '0',
|
configVersion: '0',
|
||||||
@@ -162,9 +167,45 @@ describe('CDR Redis Stream contract', () => {
|
|||||||
expect(parsed.node_id).toBe('a1');
|
expect(parsed.node_id).toBe('a1');
|
||||||
expect(parsed.opensips_instance).toBe('opensips-a1');
|
expect(parsed.opensips_instance).toBe('opensips-a1');
|
||||||
expect(parsed.rtpengine_node).toBe('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);
|
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 () => {
|
it('creates the consumer group idempotently', async () => {
|
||||||
const redis = new FakeRedis();
|
const redis = new FakeRedis();
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export interface CdrStreamEvent {
|
|||||||
source_ip: string;
|
source_ip: string;
|
||||||
caller: string;
|
caller: string;
|
||||||
callee: 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_id: string;
|
||||||
vendor_gateway_id: string;
|
vendor_gateway_id: string;
|
||||||
line_group_id: string;
|
line_group_id: string;
|
||||||
@@ -69,6 +74,11 @@ export interface CdrStreamPublishInput {
|
|||||||
sourceIp: string;
|
sourceIp: string;
|
||||||
caller?: string;
|
caller?: string;
|
||||||
callee?: string;
|
callee?: string;
|
||||||
|
calleeCityCode?: string;
|
||||||
|
calleeCityName?: string;
|
||||||
|
calleeProvinceName?: string;
|
||||||
|
calleeOperator?: string;
|
||||||
|
calleeNumberType?: string;
|
||||||
vendorId?: string;
|
vendorId?: string;
|
||||||
vendorGatewayId?: string;
|
vendorGatewayId?: string;
|
||||||
lineGroupId?: string;
|
lineGroupId?: string;
|
||||||
@@ -154,6 +164,11 @@ export function buildCdrStreamEvent(input: CdrStreamPublishInput): CdrStreamEven
|
|||||||
source_ip: input.sourceIp,
|
source_ip: input.sourceIp,
|
||||||
caller: input.caller ?? '',
|
caller: input.caller ?? '',
|
||||||
callee: input.callee ?? '',
|
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_id: input.vendorId ?? '',
|
||||||
vendor_gateway_id: input.vendorGatewayId ?? '',
|
vendor_gateway_id: input.vendorGatewayId ?? '',
|
||||||
line_group_id: input.lineGroupId ?? '',
|
line_group_id: input.lineGroupId ?? '',
|
||||||
@@ -220,6 +235,11 @@ export function parseCdrStreamEvent(fields: string[]): ParsedCdrStreamEvent {
|
|||||||
source_ip: parsed.source_ip,
|
source_ip: parsed.source_ip,
|
||||||
caller: parsed.caller ?? '',
|
caller: parsed.caller ?? '',
|
||||||
callee: parsed.callee ?? '',
|
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_id: parsed.vendor_id ?? '',
|
||||||
vendor_gateway_id: parsed.vendor_gateway_id ?? '',
|
vendor_gateway_id: parsed.vendor_gateway_id ?? '',
|
||||||
line_group_id: parsed.line_group_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")
|
@@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 {
|
enum CdrRatingStatus {
|
||||||
UNRATED
|
UNRATED
|
||||||
RATED
|
RATED
|
||||||
@@ -283,6 +310,7 @@ model VendorGateway {
|
|||||||
|
|
||||||
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
||||||
forbiddenPeriods VendorGatewayForbiddenPeriod[]
|
forbiddenPeriods VendorGatewayForbiddenPeriod[]
|
||||||
|
blockedRegions VendorGatewayBlockedRegion[]
|
||||||
codecs VendorGatewayCodec[]
|
codecs VendorGatewayCodec[]
|
||||||
prefixRules VendorGatewayPrefixRule[]
|
prefixRules VendorGatewayPrefixRule[]
|
||||||
lineGroupItems LandingLineGroupItem[]
|
lineGroupItems LandingLineGroupItem[]
|
||||||
@@ -295,6 +323,125 @@ model VendorGateway {
|
|||||||
@@map("vendor_gateways")
|
@@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 {
|
model VendorGatewayForbiddenPeriod {
|
||||||
id String @id @db.VarChar(32)
|
id String @id @db.VarChar(32)
|
||||||
vendorGatewayId String @map("vendor_gateway_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)
|
sourceIp String? @map("source_ip") @db.VarChar(45)
|
||||||
caller String @db.VarChar(64)
|
caller String @db.VarChar(64)
|
||||||
callee 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)
|
vendorId String? @map("vendor_id") @db.VarChar(32)
|
||||||
vendorGatewayId String? @map("vendor_gateway_id") @db.VarChar(32)
|
vendorGatewayId String? @map("vendor_gateway_id") @db.VarChar(32)
|
||||||
lineGroupId String? @map("line_group_id") @db.VarChar(32)
|
lineGroupId String? @map("line_group_id") @db.VarChar(32)
|
||||||
@@ -434,6 +586,8 @@ model RawCdr {
|
|||||||
@@unique([callId, endedAt])
|
@@unique([callId, endedAt])
|
||||||
@@index([customerId, startedAt])
|
@@index([customerId, startedAt])
|
||||||
@@index([vendorId, startedAt])
|
@@index([vendorId, startedAt])
|
||||||
|
@@index([calleeCityCode, startedAt])
|
||||||
|
@@index([calleeOperator, startedAt])
|
||||||
@@index([sipCode])
|
@@index([sipCode])
|
||||||
@@index([ratingStatus])
|
@@index([ratingStatus])
|
||||||
@@map("raw_cdrs")
|
@@map("raw_cdrs")
|
||||||
|
|||||||
+12
-1
@@ -4,6 +4,8 @@ const prisma = new PrismaClient();
|
|||||||
|
|
||||||
const permissions = [
|
const permissions = [
|
||||||
['dashboard.view', 'dashboard', 'view', '查看 Dashboard 指标'],
|
['dashboard.view', 'dashboard', 'view', '查看 Dashboard 指标'],
|
||||||
|
['active_calls.view', 'active_calls', 'view', '查看当前通话'],
|
||||||
|
['active_calls.manage', 'active_calls', 'manage', '强制挂断当前通话'],
|
||||||
['customers.view', 'customers', 'view', '查看客户'],
|
['customers.view', 'customers', 'view', '查看客户'],
|
||||||
['customers.manage', 'customers', 'manage', '管理客户'],
|
['customers.manage', 'customers', 'manage', '管理客户'],
|
||||||
['customer_gateways.view', 'customer_gateways', 'view', '查看客户网关'],
|
['customer_gateways.view', 'customer_gateways', 'view', '查看客户网关'],
|
||||||
@@ -14,6 +16,8 @@ const permissions = [
|
|||||||
['vendor_gateways.manage', 'vendor_gateways', 'manage', '管理落地网关'],
|
['vendor_gateways.manage', 'vendor_gateways', 'manage', '管理落地网关'],
|
||||||
['line_groups.view', 'line_groups', 'view', '查看落地线路组'],
|
['line_groups.view', 'line_groups', 'view', '查看落地线路组'],
|
||||||
['line_groups.manage', 'line_groups', 'manage', '管理落地线路组'],
|
['line_groups.manage', 'line_groups', 'manage', '管理落地线路组'],
|
||||||
|
['number_library.view', 'number_library', 'view', '查看号码库'],
|
||||||
|
['number_library.manage', 'number_library', 'manage', '管理号码库'],
|
||||||
['recharges.view', 'recharges', 'view', '查看充值流水'],
|
['recharges.view', 'recharges', 'view', '查看充值流水'],
|
||||||
['recharges.manage', 'recharges', 'manage', '执行客户或供应商充值'],
|
['recharges.manage', 'recharges', 'manage', '执行客户或供应商充值'],
|
||||||
['cdr.view', 'cdr', 'view', '查看话单'],
|
['cdr.view', 'cdr', 'view', '查看话单'],
|
||||||
@@ -40,6 +44,7 @@ const roles = [
|
|||||||
description: '客户、网关、线路和话单运营',
|
description: '客户、网关、线路和话单运营',
|
||||||
permissionIds: [
|
permissionIds: [
|
||||||
'dashboard.view',
|
'dashboard.view',
|
||||||
|
'active_calls.view',
|
||||||
'customers.view',
|
'customers.view',
|
||||||
'customers.manage',
|
'customers.manage',
|
||||||
'customer_gateways.view',
|
'customer_gateways.view',
|
||||||
@@ -48,6 +53,8 @@ const roles = [
|
|||||||
'vendor_gateways.view',
|
'vendor_gateways.view',
|
||||||
'line_groups.view',
|
'line_groups.view',
|
||||||
'line_groups.manage',
|
'line_groups.manage',
|
||||||
|
'number_library.view',
|
||||||
|
'number_library.manage',
|
||||||
'cdr.view',
|
'cdr.view',
|
||||||
'recordings.play'
|
'recordings.play'
|
||||||
]
|
]
|
||||||
@@ -75,9 +82,13 @@ const roles = [
|
|||||||
'vendor_gateways.view',
|
'vendor_gateways.view',
|
||||||
'vendor_gateways.manage',
|
'vendor_gateways.manage',
|
||||||
'line_groups.view',
|
'line_groups.view',
|
||||||
|
'number_library.view',
|
||||||
|
'number_library.manage',
|
||||||
'cdr.view',
|
'cdr.view',
|
||||||
'recordings.play',
|
'recordings.play',
|
||||||
'audit.view'
|
'audit.view',
|
||||||
|
'active_calls.view',
|
||||||
|
'active_calls.manage'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user