feat: add number library routing and cdr location support

This commit is contained in:
hectorzhao
2026-06-24 11:39:32 +08:00
parent 5fa1bd35e9
commit 7057fd3c42
63 changed files with 6361 additions and 566 deletions
+243 -4
View File
@@ -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 和当前 releaseA 发布仍需先备份 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` activeAPI 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` 通过并 activeRedis 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` activeAPI 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` activeAPI 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` activeB `lisglosips@api``nginx``mysql``redis-server` activeAPI 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 均 activeAPI ready 返回 okNginx 配置测试成功。`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 均 activeAPI ready 返回 okNginx 配置测试成功。`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、灰度与上线完成
- 状态:已完成 - 状态:已完成
+284 -12
View File
@@ -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` 和乐观锁版本号。
- 时间存 UTCAPI 返回 ISO 8601。 - 时间存 UTCAPI 返回 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("'", "'\\''")}'`;
}
+6
View File
@@ -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,
+41 -6
View File
@@ -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
}; };
} }
+15 -1
View File
@@ -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] ?? '';
+3 -1
View File
@@ -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 {}
+13 -1
View File
@@ -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);
+63 -2
View File
@@ -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);
}
}
+17
View File
@@ -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);
});
});
+55
View File
@@ -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);
}
} }
+39 -1
View File
@@ -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.` });
+26 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+88 -1
View File
@@ -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 : '请求失败,请稍后重试。';
} }
+183
View File
@@ -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;
} }
+7 -10
View File
@@ -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);
}); });
}); });
+19 -37
View File
@@ -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' })
])
);
});
});
+128 -3
View File
@@ -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);
+256
View File
@@ -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')
+58 -8
View File
@@ -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())
+4
View File
@@ -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'
+17 -1
View File
@@ -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');
+41
View File
@@ -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();
+20
View File
@@ -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;
+154
View File
@@ -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
View File
@@ -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'
] ]
} }
]; ];